+
+
+
+
+ Failed to fetch map tiles. You appear to be offline or off-grid.
+
+
+ Please use an
+ Offline Map
+ with MBTiles, or configure a local tile/geocoder server in the map settings.
+
+
+
+
@@ -1036,6 +1291,7 @@ import XYZ from "ol/source/XYZ";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
+import * as mdi from "@mdi/js";
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { defaults as defaultControls } from "ol/control";
@@ -1057,12 +1313,14 @@ import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
import Toggle from "../forms/Toggle.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
+import MiniChat from "./MiniChat.vue";
export default {
name: "MapPage",
components: {
MaterialDesignIcon,
Toggle,
+ MiniChat,
},
data() {
return {
@@ -1082,7 +1340,10 @@ export default {
telemetryList: [],
markerSource: null,
markerLayer: null,
+ historySource: null,
+ historyLayer: null,
selectedMarker: null,
+ isMiniChatOpen: false,
queryMarker: null,
discoveredMarkers: [],
@@ -1125,6 +1386,8 @@ export default {
mbtilesList: [],
mbtilesDir: "",
isMapLoaded: false,
+ tileErrorCount: 0,
+ showOfflineHint: false,
// drawing tools
draw: null,
@@ -1159,6 +1422,7 @@ export default {
editingFeature: null,
noteText: "",
hoveredFeature: null,
+ hoveredMarker: null,
noteOverlay: null,
showNoteModal: false,
showSaveDrawingModal: false,
@@ -1177,6 +1441,9 @@ export default {
};
},
computed: {
+ trackedPeers() {
+ return this.telemetryList.filter((t) => t.is_tracking);
+ },
estimatedTiles() {
if (!this.selectedBbox) return 0;
const [minLon, minLat, maxLon, maxLat] = this.selectedBbox;
@@ -1195,6 +1462,12 @@ export default {
},
},
watch: {
+ selectedMarker(newVal, oldVal) {
+ // Close mini-chat if the selected peer changed
+ if (!newVal || !oldVal || newVal.telemetry?.destination_hash !== oldVal.telemetry?.destination_hash) {
+ this.isMiniChatOpen = false;
+ }
+ },
showSaveDrawingModal(val) {
if (val) {
this.$nextTick(() => {
@@ -1273,6 +1546,7 @@ export default {
// add a temporary marker for the query target
const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
+ originalCoord: fromLonLat([lon, lat]),
});
feature.setStyle(
this.createMarkerStyle({
@@ -1294,9 +1568,11 @@ export default {
if (this.map) {
this.map.on("moveend", () => {
const view = this.map.getView();
- this.currentCenter = toLonLat(view.getCenter());
- this.currentZoom = view.getZoom();
+ this.currentCenter =
+ view && typeof view.getCenter === "function" ? toLonLat(view.getCenter()) : this.currentCenter;
+ this.currentZoom = view && typeof view.getZoom === "function" ? view.getZoom() : this.currentZoom;
this.saveMapState();
+ this.updateMarkers();
});
}
@@ -1580,38 +1856,79 @@ export default {
// Right-click context menu
this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);
+ // setup history layer (trail)
+ this.historySource = new VectorSource();
+ this.historyLayer = new VectorLayer({
+ source: this.historySource,
+ style: new Style({
+ stroke: new Stroke({
+ color: "rgba(234, 179, 8, 0.6)", // yellow-500 light
+ width: 3,
+ lineDash: [10, 10], // dashed trail
+ }),
+ }),
+ zIndex: 40,
+ });
+ this.map.addLayer(this.historyLayer);
+
// setup telemetry markers
this.markerSource = new VectorSource();
this.markerLayer = new VectorLayer({
source: this.markerSource,
style: (feature) => {
+ const isHovered = this.hoveredMarker === feature;
+ const scale = isHovered ? 2.0 : 1.6;
+ const zIndex = isHovered ? 1000 : 100;
+
const t = feature.get("telemetry");
const peer = feature.get("peer");
- const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
-
- // Calculate staleness
- const now = Date.now();
- const updatedAt = t.updated_at
- ? new Date(t.updated_at).getTime()
- : t.timestamp
- ? t.timestamp * 1000
- : now;
- const isStale = now - updatedAt > 10 * 60 * 1000;
+ const disc = feature.get("discovered");
+ let displayName = "";
+ let isStale = false;
let iconColor = "#2563eb";
let bgColor = "#ffffff";
+ let iconPath = null;
- if (peer?.lxmf_user_icon) {
- iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
- bgColor = peer.lxmf_user_icon.background_colour || bgColor;
+ if (t) {
+ displayName = peer?.display_name || t.destination_hash.substring(0, 8);
+ // Calculate staleness
+ const now = Date.now();
+ const updatedAt = t.updated_at
+ ? new Date(t.updated_at).getTime()
+ : t.timestamp
+ ? t.timestamp * 1000
+ : now;
+ isStale = now - updatedAt > 10 * 60 * 1000;
+
+ if (peer?.lxmf_user_icon) {
+ iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
+ bgColor = peer.lxmf_user_icon.background_colour || bgColor;
+ if (peer.lxmf_user_icon.icon_name) {
+ iconPath = this.getMdiPath(peer.lxmf_user_icon.icon_name);
+ }
+ }
+ } else if (disc) {
+ displayName = disc.name;
+ iconColor = "#10b981"; // emerald-500
+ bgColor = "#d1fae5"; // emerald-100
+ iconPath = "M12 2L2 7L12 12L22 7L12 2Z M2 17L12 22L22 17 M2 12L12 17L22 12"; // router-wireless style path
+ } else if (feature === this.queryMarker) {
+ displayName = "Search Result";
+ iconColor = "#ef4444";
}
- return this.createMarkerStyle({
+ const style = this.createMarkerStyle({
iconColor,
bgColor,
label: displayName,
isStale,
+ iconPath,
+ scale,
+ isTracking: t ? t.is_tracking : false,
});
+ style.setZIndex(zIndex);
+ return style;
},
zIndex: 100,
});
@@ -1622,7 +1939,7 @@ export default {
this.handleMapClick(evt);
this.closeContextMenu();
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
- if (feature && feature.get("telemetry")) {
+ if (feature && (feature.get("telemetry") || feature.get("discovered"))) {
this.onMarkerClick(feature);
} else {
this.selectedMarker = null;
@@ -1676,14 +1993,21 @@ export default {
return url.startsWith("/") || url.startsWith("./") || !url.startsWith("http");
}
},
- isDefaultOnlineUrl(url, type) {
+ isDefaultOnlineUrl(url) {
if (!url) return false;
- if (type === "tile") {
- return url.includes("tile.openstreetmap.org") || url.includes("openstreetmap.org");
- } else if (type === "nominatim") {
- return url.includes("nominatim.openstreetmap.org") || url.includes("openstreetmap.org");
- }
- return false;
+ const onlinePatterns = [
+ "openstreetmap.org",
+ "cartocdn.com",
+ "thunderforest.com",
+ "stamen.com",
+ "google.com",
+ "mapbox.com",
+ "arcgisonline.com",
+ "wmflabs.org",
+ "maptiler.com",
+ ];
+ const lowerUrl = url.toLowerCase();
+ return onlinePatterns.some((pattern) => lowerUrl.includes(pattern));
},
async checkApiConnection(url) {
if (!url || this.isLocalUrl(url)) {
@@ -1714,13 +2038,22 @@ export default {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const customTileUrl = this.tileServerUrl || defaultTileUrl;
const isCustomLocal = this.isLocalUrl(customTileUrl);
- const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl, "tile");
+ const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl);
let tileUrl;
if (isOffline) {
- if (isCustomLocal || (!isDefaultOnline && customTileUrl !== defaultTileUrl)) {
+ // If it's a known online URL, force offline tiles from MBTiles
+ if (isDefaultOnline) {
+ tileUrl = "/api/v1/map/tiles/{z}/{x}/{y}.png";
+ } else if (isCustomLocal) {
+ // It's a local/mesh URL, allow it
+ tileUrl = customTileUrl;
+ } else if (customTileUrl !== defaultTileUrl) {
+ // It's a custom URL that isn't a known online one,
+ // assume it might be a local mesh server with a domain.
tileUrl = customTileUrl;
} else {
+ // Fallback to offline MBTiles
tileUrl = "/api/v1/map/tiles/{z}/{x}/{y}.png";
}
} else {
@@ -1732,6 +2065,24 @@ export default {
crossOrigin: "anonymous",
});
+ // Track tile load errors to notify user if they appear to be offline
+ if (source && typeof source.on === "function") {
+ source.on("tileloaderror", () => {
+ if (!isOffline) {
+ this.tileErrorCount++;
+ if (this.tileErrorCount > 5) {
+ this.showOfflineHint = true;
+ // Reset count after showing hint to avoid multiple triggers
+ this.tileErrorCount = 0;
+ // Auto-hide hint after 30 seconds
+ setTimeout(() => {
+ this.showOfflineHint = false;
+ }, 30000);
+ }
+ }
+ });
+ }
+
const originalTileLoadFunction = source.getTileLoadFunction();
if (isOffline) {
@@ -1847,16 +2198,19 @@ export default {
return;
}
+ this.tileErrorCount = 0;
+ this.showOfflineHint = false;
+
if (enabled) {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl);
- const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl, "tile");
+ const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl);
const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl;
const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl);
- const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
+ const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl);
const hasCustomNominatim = this.nominatimApiUrl && this.nominatimApiUrl !== defaultNominatimUrl;
if (hasCustomTile && !isCustomTileLocal && !isDefaultTileOnline) {
@@ -1895,6 +2249,8 @@ export default {
},
async toggleCaching(enabled) {
this.cachingEnabled = enabled;
+ this.tileErrorCount = 0;
+ this.showOfflineHint = false;
try {
await window.axios.patch("/api/v1/config", {
map_tile_cache_enabled: enabled,
@@ -1955,6 +2311,9 @@ export default {
if (this.exportStatus.status === "completed" || this.exportStatus.status === "failed") {
clearInterval(this.exportInterval);
this.isExporting = false;
+ if (this.exportStatus.status === "completed") {
+ this.loadMBTilesList();
+ }
}
} catch {
clearInterval(this.exportInterval);
@@ -2053,8 +2412,16 @@ export default {
}
},
setTileServer(type) {
+ this.tileErrorCount = 0;
+ this.showOfflineHint = false;
if (type === "osm") {
this.tileServerUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+ } else if (type === "carto-dark") {
+ this.tileServerUrl = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
+ } else if (type === "carto-voyager") {
+ this.tileServerUrl = "https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
+ } else if (type === "carto-light") {
+ this.tileServerUrl = "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
}
this.saveTileServerUrl();
},
@@ -2171,7 +2538,7 @@ export default {
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomLocal = this.isLocalUrl(this.nominatimApiUrl);
- const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
+ const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl);
if (this.offlineEnabled) {
if (isCustomLocal || (!isDefaultOnline && this.nominatimApiUrl !== defaultNominatimUrl)) {
@@ -2223,6 +2590,7 @@ export default {
console.error("Search error:", e);
if (e.message.includes("Failed to fetch") || e.message.includes("NetworkError")) {
this.searchError = this.$t("map.search_connection_error");
+ this.showOfflineHint = true;
} else {
this.searchError = this.$t("map.search_error") + ": " + e.message;
}
@@ -2641,9 +3009,25 @@ export default {
} else {
this.hoveredFeature = null;
}
+
+ // Handle marker hover effects
+ const isMarker = feature.get("telemetry") || feature.get("discovered");
+ if (isMarker && this.hoveredMarker !== feature) {
+ const oldHovered = this.hoveredMarker;
+ this.hoveredMarker = feature;
+ // Trigger style refresh
+ feature.changed();
+ if (oldHovered) oldHovered.changed();
+ }
+
this.map.getTargetElement().style.cursor = "pointer";
} else {
this.hoveredFeature = null;
+ if (this.hoveredMarker) {
+ const oldHovered = this.hoveredMarker;
+ this.hoveredMarker = null;
+ oldHovered.changed();
+ }
this.map.getTargetElement().style.cursor = "";
}
},
@@ -2936,7 +3320,21 @@ export default {
},
goToMyLocation() {
- // Priority 1: Use telemetry data if available for our own hash
+ // Priority 1: Use manual location if configured
+ if (this.config?.location_source === "manual") {
+ const lat = parseFloat(this.config.location_manual_lat);
+ const lon = parseFloat(this.config.location_manual_lon);
+ if (!isNaN(lat) && !isNaN(lon)) {
+ this.map.getView().animate({
+ center: fromLonLat([lon, lat]),
+ zoom: 15,
+ duration: 1000,
+ });
+ return;
+ }
+ }
+
+ // Priority 2: Use telemetry data if available for our own hash
if (this.config && this.config.identity_hash) {
const myTelemetry = this.telemetryList.find((t) => t.destination_hash === this.config.identity_hash);
if (myTelemetry && myTelemetry.telemetry?.location) {
@@ -3003,66 +3401,60 @@ export default {
geometry: new Point(coord),
telemetry: t,
peer: this.peers[t.destination_hash],
+ originalCoord: coord,
});
addFeatureToGroup(coord, feature);
}
// Process query marker
if (this.queryMarker) {
- const coord = this.queryMarker.getGeometry().getCoordinates();
+ const coord = this.queryMarker.get("originalCoord") || this.queryMarker.getGeometry().getCoordinates();
+ if (!this.queryMarker.get("originalCoord")) this.queryMarker.set("originalCoord", coord);
addFeatureToGroup(coord, this.queryMarker);
}
// Process discovered markers
if (this.discoveredMarkers && this.discoveredMarkers.length > 0) {
for (const feature of this.discoveredMarkers) {
- const coord = feature.getGeometry().getCoordinates();
+ const coord = feature.get("originalCoord") || feature.getGeometry().getCoordinates();
+ if (!feature.get("originalCoord")) feature.set("originalCoord", coord);
addFeatureToGroup(coord, feature);
}
}
- // Now handle groups (Marker Explosion)
+ // Now handle groups (Marker Clustering)
const view = this.map.getView();
- const resolution = view.getResolution();
- const offsetDist = resolution * 40; // 40 pixels offset
+ const resolution = view && typeof view.getResolution === "function" ? view.getResolution() : 1;
+ const offsetDist = resolution * 8; // Small 8 pixel offset to show they are separate
Object.entries(featuresByCoord).forEach(([coordStr, features]) => {
const trueCoord = coordStr.split(",").map(Number);
if (features.length === 1) {
- this.markerSource.addFeature(features[0]);
+ const feature = features[0];
+ const originalCoord = feature.get("originalCoord");
+ if (originalCoord) {
+ feature.setGeometry(new Point(originalCoord));
+ }
+ this.markerSource.addFeature(feature);
} else {
features.forEach((feature, index) => {
const angle = (index / features.length) * 2 * Math.PI;
+ const originalCoord = feature.get("originalCoord") || trueCoord;
const offsetCoord = [
- trueCoord[0] + Math.cos(angle) * offsetDist,
- trueCoord[1] + Math.sin(angle) * offsetDist,
+ originalCoord[0] + Math.cos(angle) * offsetDist,
+ originalCoord[1] + Math.sin(angle) * offsetDist,
];
// Move the marker to offset position
feature.setGeometry(new Point(offsetCoord));
this.markerSource.addFeature(feature);
-
- // Draw dashed line to true position
- const lineFeature = new Feature({
- geometry: new LineString([offsetCoord, trueCoord]),
- });
- lineFeature.setStyle(
- new Style({
- stroke: new Stroke({
- color: "rgba(59, 130, 246, 0.6)",
- width: 1.5,
- lineDash: [4, 4],
- }),
- })
- );
- this.markerSource.addFeature(lineFeature);
});
}
});
},
- createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
- const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
+ createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath, scale = 1.6, isTracking = false }) {
+ const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}-${scale}-${isTracking}`;
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
const markerFill = isStale ? "#d1d5db" : bgColor;
@@ -3071,19 +3463,37 @@ export default {
iconPath ||
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
- const svg = `
`;
+ let svg = "";
+ if (isTracking) {
+ // Add a MeshChatX specific pulsing ring for tracking
+ svg = `
`;
+ } else {
+ svg = `
`;
+ }
+
const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
const style = new Style({
image: new Icon({
src: src,
anchor: [0.5, 1],
- scale: 1.6, // Reduced from 2.5
- imgSize: [24, 24],
+ scale: scale,
+ imgSize: isTracking ? [32, 32] : [24, 24],
}),
text: new Text({
text: label,
- offsetY: -45, // Adjusted from -60
+ offsetY: isTracking ? -35 - scale * 12 : -25 - scale * 12, // Dynamic offset based on scale
font: "bold 12px sans-serif",
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
@@ -3097,18 +3507,67 @@ export default {
this.selectedMarker = {
telemetry: feature.get("telemetry"),
peer: feature.get("peer"),
+ discovered: feature.get("discovered"),
};
+
+ // draw path for telemetry markers
+ if (this.selectedMarker.telemetry) {
+ this.drawTelemetryPath(this.selectedMarker.telemetry.destination_hash);
+ } else {
+ this.clearTelemetryPath();
+ }
+ },
+ async drawTelemetryPath(hash) {
+ this.clearTelemetryPath();
+ try {
+ const response = await window.axios.get(`/api/v1/telemetry/history/${hash}?limit=50`);
+ const history = response.data.telemetry;
+ if (!history || history.length < 2) return;
+
+ // collect coordinates
+ const coords = [];
+ for (const entry of history) {
+ const loc = entry.telemetry?.location;
+ if (loc && loc.latitude !== undefined && loc.longitude !== undefined) {
+ coords.push(fromLonLat([loc.longitude, loc.latitude]));
+ }
+ }
+
+ if (coords.length < 2) return;
+
+ // create line feature
+ const line = new LineString(coords);
+ const feature = new Feature({
+ geometry: line,
+ type: "history_trail",
+ });
+
+ if (this.historySource) {
+ this.historySource.addFeature(feature);
+ }
+ } catch (e) {
+ console.error("Failed to draw telemetry path", e);
+ }
+ },
+ clearTelemetryPath() {
+ if (this.historySource) {
+ this.historySource.clear();
+ }
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
if (json.type === "lxmf.telemetry") {
// Find and update or add to telemetryList
const index = this.telemetryList.findIndex((t) => t.destination_hash === json.destination_hash);
+ const oldEntry = index !== -1 ? this.telemetryList[index] : null;
const entry = {
destination_hash: json.destination_hash,
timestamp: json.timestamp,
telemetry: json.telemetry,
updated_at: new Date().toISOString(),
+ is_tracking:
+ json.is_tracking !== undefined ? json.is_tracking : oldEntry ? oldEntry.is_tracking : false,
+ physical_link: json.physical_link || oldEntry?.physical_link,
};
if (index !== -1) {
@@ -3116,18 +3575,69 @@ export default {
} else {
this.telemetryList.push(entry);
}
+
+ // Show notification for tracked peers
+ if (entry.telemetry?.location) {
+ const peer = this.peers[json.destination_hash];
+ const name = peer?.display_name || json.destination_hash.substring(0, 8);
+ const isTracked = this.telemetryList.find(
+ (t) => t.destination_hash === json.destination_hash
+ )?.is_tracking;
+
+ if (isTracked) {
+ ToastUtils.info(
+ `Live update: ${name} is at ${entry.telemetry.location.latitude.toFixed(4)}, ${entry.telemetry.location.longitude.toFixed(4)}`
+ );
+ }
+
+ // Update trail if this marker is currently selected
+ if (this.selectedMarker?.telemetry?.destination_hash === json.destination_hash) {
+ this.drawTelemetryPath(json.destination_hash);
+ }
+ }
+
this.updateMarkers();
}
},
formatTimestamp(ts) {
return new Date(ts * 1000).toLocaleString();
},
+ getMdiPath(iconName) {
+ if (!iconName) return null;
+ // same logic as MaterialDesignIcon.vue
+ const mdiName =
+ "mdi" +
+ iconName
+ .split("-")
+ .filter((word) => word.length > 0)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join("");
+ return mdi[mdiName] || null;
+ },
openChat(hash) {
this.$router.push({
name: "messages",
params: { destinationHash: hash },
});
},
+ async toggleTracking(hash) {
+ try {
+ const response = await window.axios.post(`/api/v1/telemetry/tracking/${hash}/toggle`, {
+ is_tracking: this.selectedMarker.telemetry.is_tracking ? false : true,
+ });
+ if (this.selectedMarker && this.selectedMarker.telemetry.destination_hash === hash) {
+ this.selectedMarker.telemetry.is_tracking = response.data.is_tracking;
+ }
+ // Also update in telemetryList
+ const t = this.telemetryList.find((t) => t.destination_hash === hash);
+ if (t) t.is_tracking = response.data.is_tracking;
+
+ ToastUtils.success(response.data.is_tracking ? "Live tracking enabled" : "Live tracking disabled");
+ } catch (e) {
+ console.error("Failed to toggle tracking", e);
+ ToastUtils.error("Failed to update tracking status");
+ }
+ },
async mapDiscoveredNodes() {
try {
const response = await window.axios.get("/api/v1/reticulum/discovered-interfaces");
@@ -3149,6 +3659,7 @@ export default {
// Add markers
const feature = new Feature({
geometry: new Point(coord),
+ originalCoord: coord,
discovered: node,
});
feature.setStyle(
diff --git a/meshchatx/src/frontend/components/map/MiniChat.vue b/meshchatx/src/frontend/components/map/MiniChat.vue
new file mode 100644
index 0000000..65e88a4
--- /dev/null
+++ b/meshchatx/src/frontend/components/map/MiniChat.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
index 3eb8660..6a8418e 100644
--- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
@@ -2,7 +2,7 @@
-
+
@@ -43,6 +43,22 @@
Delete Message History
@@ -70,7 +86,18 @@ export default {
required: true,
},
},
- emits: ["conversation-deleted", "set-custom-display-name", "block-status-changed", "popout"],
+ emits: [
+ "conversation-deleted",
+ "set-custom-display-name",
+ "block-status-changed",
+ "popout",
+ "view-telemetry-history",
+ ],
+ data() {
+ return {
+ contact: null,
+ };
+ },
computed: {
isBlocked() {
if (!this.peer) {
@@ -79,7 +106,72 @@ export default {
return GlobalState.blockedDestinations.some((b) => b.destination_hash === this.peer.destination_hash);
},
},
+ watch: {
+ peer: {
+ immediate: true,
+ handler() {
+ this.fetchContact();
+ },
+ },
+ },
+ mounted() {
+ GlobalEmitter.on("contact-updated", this.onContactUpdated);
+ },
+ unmounted() {
+ GlobalEmitter.off("contact-updated", this.onContactUpdated);
+ },
methods: {
+ onContactUpdated(data) {
+ if (this.peer?.destination_hash === data.remote_identity_hash) {
+ this.fetchContact();
+ }
+ },
+ async fetchContact() {
+ if (!this.peer || !this.peer.destination_hash) return;
+ try {
+ const response = await window.axios.get(
+ `/api/v1/telephone/contacts/check/${this.peer.destination_hash}`
+ );
+ if (response.data.is_contact) {
+ this.contact = response.data.contact;
+ } else {
+ this.contact = null;
+ }
+ } catch (e) {
+ console.error("Failed to fetch contact", e);
+ }
+ },
+ async onToggleTelemetryTrust() {
+ const newStatus = !this.contact?.is_telemetry_trusted;
+ try {
+ if (!this.contact) {
+ // create contact first
+ await window.axios.post("/api/v1/telephone/contacts", {
+ name: this.peer.display_name,
+ remote_identity_hash: this.peer.destination_hash,
+ is_telemetry_trusted: true,
+ });
+ await this.fetchContact();
+ } else {
+ await window.axios.patch(`/api/v1/telephone/contacts/${this.contact.id}`, {
+ is_telemetry_trusted: newStatus,
+ });
+ this.contact.is_telemetry_trusted = newStatus;
+ }
+ GlobalEmitter.emit("contact-updated", {
+ remote_identity_hash: this.peer.destination_hash,
+ is_telemetry_trusted: newStatus,
+ });
+ DialogUtils.alert(
+ newStatus
+ ? this.$t("app.telemetry_trust_granted_alert")
+ : this.$t("app.telemetry_trust_revoked_alert")
+ );
+ } catch (e) {
+ DialogUtils.alert(this.$t("app.telemetry_trust_failed"));
+ console.error(e);
+ }
+ },
async onBlockDestination() {
if (
!(await DialogUtils.confirm(
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index fd09aaf..6911874 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -57,24 +57,32 @@
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}