Voice Note
-
@@ -775,6 +730,15 @@
{{ $t("messages.add_files") }}
+
+
+ Paste
+
-
-
-
+
@@ -917,7 +877,6 @@
import Utils from "../../js/Utils";
import DialogUtils from "../../js/DialogUtils";
import MicrophoneRecorder from "../../js/MicrophoneRecorder";
-import NotificationUtils from "../../js/NotificationUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import AddAudioButton from "./AddAudioButton.vue";
import dayjs from "dayjs";
@@ -928,10 +887,13 @@ import SendMessageButton from "./SendMessageButton.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
import AddImageButton from "./AddImageButton.vue";
+import AudioWaveformPlayer from "./AudioWaveformPlayer.vue";
import IconButton from "../IconButton.vue";
+import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import PaperMessageModal from "./PaperMessageModal.vue";
+import GlobalState from "../../js/GlobalState";
export default {
name: "ConversationViewer",
@@ -942,9 +904,16 @@ export default {
MaterialDesignIcon,
SendMessageButton,
AddAudioButton,
+ AudioWaveformPlayer,
PaperMessageModal,
+ LxmfUserIcon,
},
props: {
+ config: {
+ type: Object,
+ required: false,
+ default: null,
+ },
myLxmfAddressHash: {
type: String,
required: true,
@@ -961,6 +930,7 @@ export default {
emits: ["close", "reload-conversations", "update:selectedPeer"],
data() {
return {
+ GlobalState,
selectedPeerPath: null,
selectedPeerLxmfStampInfo: null,
selectedPeerSignalMetrics: null,
@@ -973,8 +943,8 @@ export default {
newMessageDeliveryMethod: null,
newMessageText: "",
- newMessageImage: null,
- newMessageImageUrl: null,
+ newMessageImages: [],
+ newMessageImageUrls: [],
newMessageAudio: null,
newMessageTelemetry: null,
newMessageFiles: [],
@@ -997,7 +967,6 @@ export default {
expandedMessageInfo: null,
imageModalUrl: null,
isSelectedPeerBlocked: false,
- blockedDestinations: [],
lxmfAudioModeToCodec2ModeMap: {
// https://github.com/markqvist/LXMF/blob/master/LXMF/LXMF.py#L21
0x01: "450PWB", // AM_CODEC2_450PWB
@@ -1017,6 +986,9 @@ export default {
};
},
computed: {
+ blockedDestinations() {
+ return GlobalState.blockedDestinations;
+ },
filteredContacts() {
if (!this.contactsSearch) return this.contacts;
const s = this.contactsSearch.toLowerCase();
@@ -1028,9 +1000,18 @@ export default {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
},
canSendMessage() {
- // can't send if empty message
+ // can send if message text is present
const messageText = this.newMessageText.trim();
- if (messageText == null || messageText === "") {
+ const hasText = messageText != null && messageText !== "";
+
+ // or if any attachments are present
+ const hasAttachments =
+ this.newMessageImages.length > 0 ||
+ this.newMessageAudio != null ||
+ this.newMessageFiles.length > 0 ||
+ this.newMessageTelemetry != null;
+
+ if (!hasText && !hasAttachments) {
return false;
}
@@ -1095,6 +1076,17 @@ export default {
this.adjustTextareaHeight();
});
},
+ "config.translator_enabled": {
+ handler() {
+ this.checkTranslator();
+ },
+ },
+ blockedDestinations: {
+ handler() {
+ this.checkIfSelectedPeerBlocked();
+ },
+ deep: true,
+ },
},
beforeUnmount() {
// stop listening for websocket messages
@@ -1108,23 +1100,15 @@ export default {
// listen for compose new message event
GlobalEmitter.on("compose-new-message", this.onComposeNewMessageEvent);
- // load blocked destinations
- this.loadBlockedDestinations();
-
// check translator
this.checkTranslator();
},
methods: {
- async loadBlockedDestinations() {
- try {
- const response = await window.axios.get("/api/v1/blocked-destinations");
- this.blockedDestinations = response.data.blocked_destinations || [];
- this.checkIfSelectedPeerBlocked();
- } catch (e) {
- console.log(e);
- }
- },
async checkTranslator() {
+ if (!this.config?.translator_enabled) {
+ this.hasTranslator = false;
+ return;
+ }
try {
const response = await window.axios.get("/api/v1/translator/languages");
this.translatorLanguages = response.data.languages || [];
@@ -1171,7 +1155,7 @@ export default {
this.isSelectedPeerBlocked = false;
return;
}
- this.isSelectedPeerBlocked = this.blockedDestinations.some(
+ this.isSelectedPeerBlocked = GlobalState.blockedDestinations.some(
(b) => b.destination_hash === this.selectedPeer.destination_hash
);
},
@@ -1235,6 +1219,9 @@ export default {
// scroll to bottom
this.scrollMessagesToBottom();
+
+ // auto load audio
+ this.autoLoadAudioAttachments();
},
async loadPrevious() {
// do nothing if already loading
@@ -1286,6 +1273,9 @@ export default {
if (chatItems.length < pageSize) {
this.hasMorePrevious = false;
}
+
+ // auto load audio
+ this.autoLoadAudioAttachments();
} catch {
// do nothing
} finally {
@@ -1377,15 +1367,13 @@ export default {
this.markConversationAsRead(conversation);
}
- // show notification for new messages if window is not focussed
- if (!document.hasFocus()) {
- NotificationUtils.showNewMessageNotification();
- }
-
// auto scroll to bottom if we want to
if (this.autoScrollOnNewMessage) {
this.scrollMessagesToBottom();
}
+
+ // auto load audio
+ this.autoLoadAudioAttachments();
},
onLxmfMessageCreated(lxmfMessage) {
// only add if it's for the current conversation
@@ -1401,6 +1389,9 @@ export default {
is_outbound: true,
});
}
+
+ // auto load audio
+ this.autoLoadAudioAttachments();
},
onLxmfMessageUpdated(lxmfMessage) {
// find existing chat item by lxmf message hash
@@ -1639,6 +1630,17 @@ export default {
this.isDownloadingAudio[chatItem.lxmf_message.hash] = false;
}
},
+ autoLoadAudioAttachments() {
+ for (const chatItem of this.chatItems) {
+ if (
+ chatItem.lxmf_message.fields?.audio &&
+ !this.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash] &&
+ !this.isDownloadingAudio[chatItem.lxmf_message.hash]
+ ) {
+ this.downloadAndDecodeAudio(chatItem);
+ }
+ }
+ },
formatAttachmentSize(attachment, type) {
if (attachment[`${type}_size`] !== undefined && attachment[`${type}_size`] !== null) {
return this.formatBytes(attachment[`${type}_size`]);
@@ -1867,17 +1869,16 @@ export default {
// add image attachment
var imageTotalSize = 0;
- if (this.newMessageImage) {
- imageTotalSize = this.newMessageImage.size;
- fields["image"] = {
- // Reticulum sends image type as "jpg", "png", "webp" etc and not "image/jpg" or "image/png"
- // From memory, Sideband would not display images if the image type has the "image/" prefix
- // https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/docs/example_plugins/view.py#L78
- // https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/main.py#L1900
- // https://github.com/markqvist/Sideband/blob/354fb08297835eab04ac69d15081a18baf0583ac/sbapp/ui/messages.py#L783
- image_type: this.newMessageImage.type.replace("image/", ""),
- image_bytes: Utils.arrayBufferToBase64(await this.newMessageImage.arrayBuffer()),
- };
+ var images = [];
+ if (this.newMessageImages.length > 0) {
+ for (const image of this.newMessageImages) {
+ imageTotalSize += image.size;
+ images.push({
+ image_type: image.type.replace("image/", ""),
+ image_bytes: Utils.arrayBufferToBase64(await image.arrayBuffer()),
+ name: image.name,
+ });
+ }
}
// add audio attachment
@@ -1906,23 +1907,81 @@ export default {
}
}
- // send message to reticulum
- const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
- delivery_method: this.newMessageDeliveryMethod,
- lxmf_message: {
- destination_hash: this.selectedPeer.destination_hash,
- content: this.newMessageText,
- fields: fields,
- },
- });
-
- // add outbound message to ui
- if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
- this.chatItems.push({
- type: "lxmf_message",
- lxmf_message: response.data.lxmf_message,
- is_outbound: true,
+ // if no images, send message as usual
+ if (images.length === 0) {
+ const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
+ delivery_method: this.newMessageDeliveryMethod,
+ lxmf_message: {
+ destination_hash: this.selectedPeer.destination_hash,
+ content: this.newMessageText,
+ fields: fields,
+ },
});
+
+ // add outbound message to ui
+ if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
+ this.chatItems.push({
+ type: "lxmf_message",
+ lxmf_message: response.data.lxmf_message,
+ is_outbound: true,
+ });
+ }
+ } else {
+ // send first image with message text and other fields
+ const firstImage = images[0];
+ const firstFields = {
+ ...fields,
+ image: { image_type: firstImage.image_type, image_bytes: firstImage.image_bytes },
+ };
+
+ const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
+ delivery_method: this.newMessageDeliveryMethod,
+ lxmf_message: {
+ destination_hash: this.selectedPeer.destination_hash,
+ content: this.newMessageText,
+ fields: firstFields,
+ },
+ });
+
+ // add outbound message to ui
+ if (!this.isLxmfMessageInUi(response.data.lxmf_message.hash)) {
+ this.chatItems.push({
+ type: "lxmf_message",
+ lxmf_message: response.data.lxmf_message,
+ is_outbound: true,
+ });
+ }
+
+ // send subsequent images as separate messages with image name as content
+ for (let i = 1; i < images.length; i++) {
+ const image = images[i];
+ const subsequentFields = {
+ image: { image_type: image.image_type, image_bytes: image.image_bytes },
+ };
+
+ try {
+ const subResponse = await window.axios.post(`/api/v1/lxmf-messages/send`, {
+ delivery_method: this.newMessageDeliveryMethod,
+ lxmf_message: {
+ destination_hash: this.selectedPeer.destination_hash,
+ content: image.name,
+ fields: subsequentFields,
+ },
+ });
+
+ // add outbound message to ui
+ if (!this.isLxmfMessageInUi(subResponse.data.lxmf_message.hash)) {
+ this.chatItems.push({
+ type: "lxmf_message",
+ lxmf_message: subResponse.data.lxmf_message,
+ is_outbound: true,
+ });
+ }
+ } catch (subError) {
+ console.error(`Failed to send image ${i + 1}:`, subError);
+ // we continue sending other images even if one fails
+ }
+ }
}
// always scroll to bottom since we just sent a message
@@ -1931,8 +1990,8 @@ export default {
// clear message inputs
this.newMessageText = "";
this.saveDraft(this.selectedPeer.destination_hash);
- this.newMessageImage = null;
- this.newMessageImageUrl = null;
+ this.newMessageImages = [];
+ this.newMessageImageUrls = [];
this.newMessageAudio = null;
this.newMessageTelemetry = null;
this.newMessageFiles = [];
@@ -2106,6 +2165,38 @@ export default {
const url = `${window.location.origin}${window.location.pathname}#/popout/messages/${encodedHash}`;
window.open(url, "_blank", "width=960,height=720,noopener");
},
+ async onStartCall() {
+ try {
+ await window.axios.get(`/api/v1/telephone/call/${this.selectedPeer.destination_hash}`);
+ } catch (e) {
+ const message = e.response?.data?.message ?? "Failed to start call";
+ DialogUtils.alert(message);
+ }
+ },
+ async pasteFromClipboard() {
+ try {
+ const text = await navigator.clipboard.readText();
+ if (text) {
+ const input = this.$refs["message-input"];
+ const start = input.selectionStart;
+ const end = input.selectionEnd;
+ const currentText = this.newMessageText || "";
+ this.newMessageText = currentText.substring(0, start) + text + currentText.substring(end);
+
+ this.$nextTick(() => {
+ input.focus();
+ const newCursorPos = start + text.length;
+ input.setSelectionRange(newCursorPos, newCursorPos);
+ // adjust height
+ input.style.height = "auto";
+ input.style.height = Math.min(input.scrollHeight, 200) + "px";
+ });
+ }
+ } catch (err) {
+ console.error("Failed to read clipboard contents: ", err);
+ ToastUtils.error("Failed to read from clipboard");
+ }
+ },
onFileInputChange: function (event) {
for (const file of event.target.files) {
this.newMessageFiles.push(file);
@@ -2114,28 +2205,29 @@ export default {
clearFileInput: function () {
this.$refs["file-input"].value = null;
},
- async removeImageAttachment() {
+ async removeImageAttachment(index) {
// ask user to confirm removing image attachment
if (!(await DialogUtils.confirm("Are you sure you want to remove this image attachment?"))) {
return;
}
// remove image
- this.newMessageImage = null;
- this.newMessageImageUrl = null;
+ this.newMessageImages.splice(index, 1);
+ this.newMessageImageUrls.splice(index, 1);
},
onImageSelected: function (imageBlob) {
// update selected file
- this.newMessageImage = imageBlob;
+ const index = this.newMessageImages.length;
+ this.newMessageImages.push(imageBlob);
// update image url when file is read
const fileReader = new FileReader();
fileReader.onload = (event) => {
- this.newMessageImageUrl = event.target.result;
+ this.newMessageImageUrls[index] = event.target.result;
};
// convert image to data url
- fileReader.readAsDataURL(this.newMessageImage);
+ fileReader.readAsDataURL(imageBlob);
},
async startRecordingAudioAttachment(args) {
// do nothing if already recording
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 27339e5..270f3fd 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -11,11 +11,20 @@
:filter-failed-only="filterFailedOnly"
:filter-has-attachments-only="filterHasAttachmentsOnly"
:is-loading="isLoadingConversations"
+ :is-loading-more="isLoadingMore"
+ :has-more-conversations="hasMoreConversations"
+ :is-loading-more-announces="isLoadingMoreAnnounces"
+ :has-more-announces="hasMoreAnnounces"
+ :peers-search-term="peersSearchTerm"
+ :total-peers-count="totalPeersCount"
@conversation-click="onConversationClick"
@peer-click="onPeerClick"
@conversation-search-changed="onConversationSearchChanged"
@conversation-filter-changed="onConversationFilterChanged"
+ @peers-search-changed="onPeersSearchChanged"
@ingest-paper-message="openIngestPaperMessageModal"
+ @load-more="loadMoreConversations"
+ @load-more-announces="loadMoreAnnounces"
/>
{
+ this.getLxmfDeliveryAnnounces();
+ }, 500);
+ },
openIngestPaperMessageModal() {
this.ingestUri = "";
this.isIngestModalOpen = true;
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index eee17d6..6a0f213 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -70,7 +70,7 @@
-
+
@@ -86,7 +86,15 @@
+
+
+ {{ GlobalState.config.banished_text }}
+
+
@@ -122,7 +137,9 @@
:title="conversation.custom_display_name ?? conversation.display_name"
:class="{
'font-semibold':
- conversation.is_unread || conversation.failed_messages_count > 0,
+ (conversation.is_unread &&
+ conversation.destination_hash !== selectedDestinationHash) ||
+ conversation.failed_messages_count > 0,
}"
>
{{ conversation.custom_display_name ?? conversation.display_name }}
@@ -143,7 +160,12 @@
-
+
+
+
+
+
-
-
-
-
+
+
{{ $t("messages.loading_conversations") }}
-
-
-
-
+
+
No Conversations
Discover peers on the Announces tab
@@ -200,21 +201,8 @@
v-else-if="conversationSearchTerm !== ''"
class="flex flex-col text-gray-900 dark:text-gray-100"
>
-
-
-
-
+
+
{{ $t("messages.no_search_results") }}
{{ $t("messages.no_search_results_conversations") }}
@@ -229,22 +217,31 @@
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
>
-
+
-
+
+
+
+ {{ GlobalState.config.banished_text }}
+
+
+
+
+
+
+
-
-
-
-
+
+
{{ $t("messages.no_peers_discovered") }}
{{ $t("messages.waiting_for_announce") }}
@@ -330,21 +332,8 @@
v-if="peersSearchTerm !== '' && peersCount > 0"
class="flex flex-col text-gray-900 dark:text-gray-100"
>
-
-
-
-
+
+
{{ $t("messages.no_search_results") }}
{{ $t("messages.no_search_results_peers") }}
@@ -358,10 +347,12 @@
@@ -355,7 +436,7 @@ export default {
@apply text-blue-600 border-blue-500 dark:text-blue-300 dark:border-blue-400;
}
.favourite-card {
- @apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
+ @apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 hover:z-10;
}
.favourite-card--active {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/60 dark:bg-blue-900/30;
@@ -368,7 +449,7 @@ export default {
@apply opacity-60 ring-2 ring-blue-300 dark:ring-blue-600;
}
.announce-card {
- @apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 hover:border-blue-400 dark:hover:border-blue-500;
+ @apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 hover:border-blue-400 dark:hover:border-blue-500 hover:z-10;
}
.announce-card--active {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/70 dark:bg-blue-900/30;
diff --git a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
index 79c4224..ecb7af3 100644
--- a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
+++ b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue
@@ -253,12 +253,14 @@ export default {
}
},
async switchIdentity(identity) {
+ if (identity.is_current) return;
+
if (!(await DialogUtils.confirm(this.$t("identities.switch_confirm", { name: identity.display_name })))) {
return;
}
try {
- this.isCreating = true; // Use isCreating as a general loading state for now
+ this.isCreating = true;
GlobalEmitter.emit("identity-switching-start");
const response = await window.axios.post("/api/v1/identities/switch", {
@@ -266,22 +268,23 @@ export default {
});
if (response.data.hotswapped) {
- // ToastUtils.success(this.$t("identities.switched")); // Removed as App.vue handles this now
- // The App.vue will handle the event and we will refresh via GlobalEmitter
+ // Success is handled by GlobalEmitter "identity-switched" which we listen to
+ ToastUtils.success(this.$t("identities.switched") || "Identity switched successfully");
} else {
- ToastUtils.success("Switch scheduled. Reloading...");
+ ToastUtils.info("Switch scheduled. Reloading application...");
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (e) {
console.error(e);
- ToastUtils.error("Failed to switch identity");
+ const errorMsg = e.response?.data?.message || "Failed to switch identity";
+ ToastUtils.error(errorMsg);
this.isCreating = false;
- // Important: hide the global overlay if there was an error
- // We'll emit an event for this or just hide it here if we had access,
- // but since it's global, let's just refresh the whole state.
- window.location.reload();
+
+ // If it was a partial failure, we might need to reload anyway to be safe,
+ // but let's try to stay on the page if hotswap just failed.
+ GlobalEmitter.emit("identity-switched"); // To clear any global loading overlays
}
},
async deleteIdentity(identity) {
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index cd5ca0d..9cad432 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -106,6 +106,117 @@
+
+
+
+
+
+
+
+ {{ $t("app.banished_effect_enabled") }}
+ {{
+ $t("app.banished_effect_description")
+ }}
+
+
+
+
+
+
+ {{ $t("app.banished_text_label") }}
+
+
+
+ {{ $t("app.banished_text_description") }}
+
+
+
+
+
+ {{ $t("app.banished_color_label") }}
+
+
+
+
+
+
+ {{ $t("app.banished_color_description") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ $t("app.desktop_open_calls_in_separate_window")
+ }}
+ {{
+ $t("app.desktop_open_calls_in_separate_window_description")
+ }}
+
+
+
+
+
+
+ {{
+ $t("app.desktop_hardware_acceleration_enabled")
+ }}
+ {{
+ $t("app.desktop_hardware_acceleration_enabled_description")
+ }}
+ {{ $t("app.requires_restart") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t("app.translator_enabled") }}
+ {{
+ $t("app.translator_description")
+ }}
+
+
+
+
+
+ {{ $t("app.libretranslate_url") }}
+
+
+
+ {{ $t("app.libretranslate_url_description") }}
+
+
+
+
+