Compare commits

..

8 Commits

Author SHA1 Message Date
jkingsman c8c8e6b549 Add channel mute 2026-04-19 19:31:26 -07:00
Jack Kingsman 491f159463 Merge pull request #205 from jkingsman/dependabot/npm_and_yarn/frontend/npm_and_yarn-916abd5bfa
Bump the npm_and_yarn group across 1 directory with 4 updates
2026-04-19 18:43:06 -07:00
jkingsman ead74e975b Update tests for vitest bump 2026-04-19 18:36:13 -07:00
dependabot[bot] 4fbd245ee4 Bump the npm_and_yarn group across 1 directory with 4 updates
Bumps the npm_and_yarn group with 3 updates in the /frontend directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite), [flatted](https://github.com/WebReflection/flatted) and [picomatch](https://github.com/micromatch/picomatch).


Updates `vite` from 6.4.1 to 6.4.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

Updates `esbuild` from 0.21.5 to 0.25.12
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.25.12)

Updates `flatted` from 3.4.0 to 3.4.2
- [Commits](https://github.com/WebReflection/flatted/compare/v3.4.0...v3.4.2)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: esbuild
  dependency-version: 0.25.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 01:15:37 +00:00
Jack Kingsman dc7ec13cc5 Instructions for full monitoring feed 2026-04-19 16:25:18 -07:00
Jack Kingsman cfa2bf575c Correct HA documentation to use the actual node name 2026-04-19 15:11:25 -07:00
Jack Kingsman e9ef68432a Make caps consistent 2026-04-19 14:51:09 -07:00
Jack Kingsman 476adf393f Merge pull request #204 from jkingsman/extended-contact-fetch-timeout
Work better with radios that are flakey around providing current contact load state (BLE?)
2026-04-19 14:12:35 -07:00
37 changed files with 708 additions and 1347 deletions
+194 -39
View File
@@ -21,25 +21,23 @@ Devices will appear in HA under **Settings > Devices & Services > MQTT** within
## How MeshCore IDs Map Into Home Assistant
RemoteTerm uses each node's public key to derive a stable short identifier:
RemoteTerm uses each node's public key to derive a stable short identifier for MQTT topics:
- Full public key: `ae92577bae6c4f1d...`
- Node ID: `ae92577bae6c` (the first 12 hex characters, lowercased)
- Example entity ID: `device_tracker.meshcore_ae92577bae6c`
- Example runtime topic: `meshcore/ae92577bae6c/gps`
- Example MQTT topic: `meshcore/ae92577bae6c/gps`
When this README shows `<node_id>`, it always means that 12-character value.
When this README shows `<node_id>`, it always means that 12-character value. Node IDs appear in:
The same node ID appears in:
- Home Assistant entity IDs
- Home Assistant discovery topics under `homeassistant/...`
- MQTT discovery topics under `homeassistant/...`
- Runtime MQTT state topics under your configured prefix, usually `meshcore/...`
You can also see these IDs in RemoteTerm's Home Assistant integration UI:
**Entity IDs** are different — HA auto-generates them from the device name and entity name, not from the node ID. For example, a radio named "MyRadio" produces entities like `binary_sensor.myradio_connected` and `event.myradio_messages`. A contact named "Alice" produces `device_tracker.alice`. You can find your actual entity IDs in **Settings > Devices & Services > MQTT** in HA, and you can rename them in HA's UI without affecting the integration.
You can also see the MQTT topic IDs in RemoteTerm's Home Assistant integration UI:
- `What gets created in Home Assistant`
- `Published Topic Summary`
- `Published topic summary`
## What Gets Created
@@ -49,8 +47,8 @@ Always created. Updates every 60 seconds.
| Entity | Type | Description |
|--------|------|-------------|
| `binary_sensor.meshcore_<radio_node_id>_connected` | Connectivity | Radio online/offline |
| `sensor.meshcore_<radio_node_id>_noise_floor` | Signal strength | Radio noise floor (dBm) |
| `binary_sensor.<radio_name>_connected` | Connectivity | Radio online/offline |
| `sensor.<radio_name>_noise_floor` | Signal strength | Radio noise floor (dBm) |
### Repeater Devices
@@ -60,13 +58,13 @@ Repeaters must first be added to the auto-telemetry tracking list in RemoteTerm'
| Entity | Type | Unit | Description |
|--------|------|------|-------------|
| `sensor.meshcore_<repeater_node_id>_battery_voltage` | Voltage | V | Battery level |
| `sensor.meshcore_<repeater_node_id>_noise_floor` | Signal strength | dBm | Local noise floor |
| `sensor.meshcore_<repeater_node_id>_last_rssi` | Signal strength | dBm | Last received signal strength |
| `sensor.meshcore_<repeater_node_id>_last_snr` | -- | dB | Last signal-to-noise ratio |
| `sensor.meshcore_<repeater_node_id>_packets_received` | -- | count | Total packets received |
| `sensor.meshcore_<repeater_node_id>_packets_sent` | -- | count | Total packets sent |
| `sensor.meshcore_<repeater_node_id>_uptime` | Duration | s | Uptime since last reboot |
| `sensor.<repeater_name>_battery_voltage` | Voltage | V | Battery level |
| `sensor.<repeater_name>_noise_floor` | Signal strength | dBm | Local noise floor |
| `sensor.<repeater_name>_last_rssi` | Signal strength | dBm | Last received signal strength |
| `sensor.<repeater_name>_last_snr` | -- | dB | Last signal-to-noise ratio |
| `sensor.<repeater_name>_packets_received` | -- | count | Total packets received |
| `sensor.<repeater_name>_packets_sent` | -- | count | Total packets sent |
| `sensor.<repeater_name>_uptime` | Duration | s | Uptime since last reboot |
If RemoteTerm already has a cached telemetry snapshot for that repeater, it republishes it on startup so HA can populate the sensors immediately instead of waiting for the next collection cycle.
@@ -76,11 +74,11 @@ One `device_tracker` per tracked contact. Updates passively whenever RemoteTerm
| Entity | Description |
|--------|-------------|
| `device_tracker.meshcore_<contact_node_id>` | GPS position (latitude/longitude) |
| `device_tracker.<contact_name>` | GPS position (latitude/longitude) |
### Message Event Entity
A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
A single radio-scoped event entity, `event.<radio_name>_messages`, fires for each message matching your configured scope. Each event carries these attributes:
| Attribute | Example | Description |
|-----------|---------|-------------|
@@ -95,9 +93,9 @@ A single radio-scoped event entity, `event.meshcore_<radio_node_id>_messages`, f
## Entity Naming
Entity IDs use the first 12 characters of the node's public key as an identifier. For example, a contact with public key `ae92577bae6c...` gets entity ID `device_tracker.meshcore_ae92577bae6c`. You can rename entities in HA's UI without affecting the integration.
HA auto-generates entity IDs by slugifying the device name and entity name. For a radio named "My Radio", entities look like `binary_sensor.my_radio_connected` and `event.my_radio_messages`. For a repeater named "Hilltop", `sensor.hilltop_battery_voltage`. For a contact named "Alice", `device_tracker.alice`. You can rename entities in HA's UI without affecting the integration.
That same 12-character node ID is also used in the MQTT topic paths. For example:
MQTT topic paths use the 12-character node ID (first 12 hex characters of the public key). For example:
- Local radio health: `meshcore/<radio_node_id>/health`
- Repeater telemetry: `meshcore/<repeater_node_id>/telemetry`
@@ -117,7 +115,7 @@ That same 12-character node ID is also used in the MQTT topic paths. For example
Notify when a tracked repeater's battery drops below a threshold.
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.meshcore_<repeater_node_id>_battery_voltage`, below `3.8`, action: notification.
**GUI:** Settings > Automations > Create > Numeric state trigger on `sensor.<repeater_name>_battery_voltage`, below `3.8`, action: notification.
**YAML:**
```yaml
@@ -125,22 +123,22 @@ automation:
- alias: "Repeater battery low"
trigger:
- platform: numeric_state
entity_id: sensor.meshcore_aabbccddeeff_battery_voltage
entity_id: sensor.hilltop_battery_voltage
below: 3.8
action:
- service: notify.mobile_app_your_phone
data:
title: "Repeater Battery Low"
message: >-
{{ state_attr('sensor.meshcore_aabbccddeeff_battery_voltage', 'friendly_name') }}
is at {{ states('sensor.meshcore_aabbccddeeff_battery_voltage') }}V
{{ state_attr('sensor.hilltop_battery_voltage', 'friendly_name') }}
is at {{ states('sensor.hilltop_battery_voltage') }}V
```
### Radio offline alert
Notify if the radio has been disconnected for more than 5 minutes.
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.meshcore_<radio_node_id>_connected`, to `off`, for `00:05:00`, action: notification.
**GUI:** Settings > Automations > Create > State trigger on `binary_sensor.<radio_name>_connected`, to `off`, for `00:05:00`, action: notification.
**YAML:**
```yaml
@@ -148,7 +146,7 @@ automation:
- alias: "Radio offline"
trigger:
- platform: state
entity_id: binary_sensor.meshcore_aabbccddeeff_connected
entity_id: binary_sensor.myradio_connected
to: "off"
for: "00:05:00"
action:
@@ -166,7 +164,7 @@ Trigger when a message arrives in a specific channel. Two approaches:
If you only care about one room, configure the HA integration's message scope to "Only listed channels" and select that room. Then every event fire is from that room.
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages`, action: notification.
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages`, action: notification.
**YAML:**
```yaml
@@ -174,7 +172,7 @@ automation:
- alias: "Emergency channel alert"
trigger:
- platform: state
entity_id: event.meshcore_aabbccddeeff_messages
entity_id: event.myradio_messages
action:
- service: notify.mobile_app_your_phone
data:
@@ -188,7 +186,7 @@ automation:
Keep scope as "All messages" and filter in the automation. The trigger is GUI, but the condition uses a one-line template.
**GUI:** Settings > Automations > Create > State trigger on `event.meshcore_<radio_node_id>_messages` > Add condition > Template > enter the template below.
**GUI:** Settings > Automations > Create > State trigger on `event.<radio_name>_messages` > Add condition > Template > enter the template below.
**YAML:**
```yaml
@@ -196,7 +194,7 @@ automation:
- alias: "Emergency channel alert"
trigger:
- platform: state
entity_id: event.meshcore_aabbccddeeff_messages
entity_id: event.myradio_messages
condition:
- condition: template
value_template: >-
@@ -218,7 +216,7 @@ automation:
- alias: "DM from Alice"
trigger:
- platform: state
entity_id: event.meshcore_aabbccddeeff_messages
entity_id: event.myradio_messages
condition:
- condition: template
value_template: >-
@@ -239,7 +237,7 @@ automation:
- alias: "Keyword alert"
trigger:
- platform: state
entity_id: event.meshcore_aabbccddeeff_messages
entity_id: event.myradio_messages
condition:
- condition: template
value_template: >-
@@ -264,7 +262,7 @@ Add a sensor card to any dashboard:
```yaml
type: sensor
entity: sensor.meshcore_aabbccddeeff_battery_voltage
entity: sensor.hilltop_battery_voltage
name: "Hilltop Repeater Battery"
```
@@ -274,14 +272,171 @@ Or an entities card for multiple repeaters:
type: entities
title: "Repeater Status"
entities:
- entity: sensor.meshcore_aabbccddeeff_battery_voltage
- entity: sensor.hilltop_battery_voltage
name: "Hilltop"
- entity: sensor.meshcore_ccdd11223344_battery_voltage
- entity: sensor.valley_battery_voltage
name: "Valley"
- entity: sensor.meshcore_eeff55667788_battery_voltage
- entity: sensor.ridge_battery_voltage
name: "Ridge"
```
### Full monitoring dashboard with message feed
This example creates a dashboard with repeater vitals, a live message feed, and a network activity graph. Replace the three slug values below to match your setup — find your entity IDs in **Settings > Devices & Services > MQTT**.
```yaml
# ┌─────────────────────────────────────────────────────┐
# │ Replace these three values to match your entities │
# │ │
# │ radio_slug: the prefix on your radio sensors │
# │ e.g. sensor.MYRADIO_noise_floor │
# │ repeater_slug: the prefix on your repeater sensors │
# │ e.g. sensor.HILLTOP_battery_voltage │
# │ message_event: your message event entity ID │
# │ e.g. event.MYRADIO_messages │
# └─────────────────────────────────────────────────────┘
#
# radio_slug: myradio
# repeater_slug: hilltop
# message_event: event.myradio_messages
```
**Step 1 — Dashboard YAML** (Settings > Dashboards > Add > edit in YAML):
```yaml
views:
- title: MeshCore
icon: mdi:radio-tower
cards:
- type: entities
title: Hilltop — Current # ← repeater name
state_color: true
entities:
- entity: sensor.hilltop_battery_voltage # ← repeater_slug
name: Battery
- entity: sensor.hilltop_noise_floor # ← repeater_slug
name: Noise Floor
- entity: sensor.hilltop_last_rssi # ← repeater_slug
name: Last RSSI
- entity: sensor.hilltop_last_snr # ← repeater_slug
name: Last SNR
- entity: sensor.hilltop_uptime # ← repeater_slug
name: Uptime
- entity: sensor.hilltop_packets_received # ← repeater_slug
name: Packets Rx
- entity: sensor.hilltop_packets_sent # ← repeater_slug
name: Packets Tx
- type: statistics-graph
title: Battery Voltage
entities:
- sensor.hilltop_battery_voltage # ← repeater_slug
stat_types: [mean, min, max]
days_to_show: 7
period: hour
- type: statistics-graph
title: Noise Floor
entities:
- sensor.hilltop_noise_floor # ← repeater_slug
stat_types: [mean, min, max]
days_to_show: 7
period: hour
- type: markdown
title: Message Feed (Last 10)
content: |
{% for i in range(1, 11) %}
{% set msg = states('input_text.meshcore_msg_' ~ i) %}
{% if msg and msg not in ['unknown', '', 'unavailable'] %}
{{ msg }}
{% endif %}
{% endfor %}
{% if states('input_text.meshcore_msg_1') in ['unknown', '', 'unavailable'] %}
*No messages yet.*
{% endif %}
- type: statistics-graph
title: Overall Packets Received
entities:
- sensor.myradio_packets_received # ← radio_slug
stat_types: [change]
days_to_show: 7
period: hour
```
**Step 2 — Message feed helpers**: create 10 text helpers named `MeshCore Msg 1` through `MeshCore Msg 10` (Settings > Helpers > Add > Text). These act as a rolling buffer for the Markdown card above.
**Step 3 — Message feed automation** (Settings > Automations > Create > edit in YAML):
```yaml
alias: MeshCore Message Feed Buffer
description: Rolling buffer of recent mesh messages for dashboard display
mode: queued
max: 10
triggers:
- trigger: state
entity_id: event.myradio_messages # ← message_event
actions:
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_10
data:
value: "{{ states('input_text.meshcore_msg_9') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_9
data:
value: "{{ states('input_text.meshcore_msg_8') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_8
data:
value: "{{ states('input_text.meshcore_msg_7') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_7
data:
value: "{{ states('input_text.meshcore_msg_6') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_6
data:
value: "{{ states('input_text.meshcore_msg_5') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_5
data:
value: "{{ states('input_text.meshcore_msg_4') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_4
data:
value: "{{ states('input_text.meshcore_msg_3') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_3
data:
value: "{{ states('input_text.meshcore_msg_2') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_2
data:
value: "{{ states('input_text.meshcore_msg_1') }}"
- action: input_text.set_value
target:
entity_id: input_text.meshcore_msg_1
data:
value: >-
{{ as_timestamp(trigger.to_state.last_changed) |
timestamp_custom('%-I:%M %p') }} |
**{% if trigger.to_state.attributes.channel_name %}{{
trigger.to_state.attributes.channel_name }}{% else %}DM{% endif %}** |
{{ trigger.to_state.attributes.sender_name or 'Unknown' }}:
{{ (trigger.to_state.attributes.text or '')[:180] }}
```
## Troubleshooting
### Devices don't appear in HA
+1 -1
View File
@@ -450,7 +450,7 @@ def _message_event_discovery_config(
device = _device_payload(radio_key, radio_name, "Radio")
topic = f"homeassistant/event/meshcore_{nid}/messages/config"
cfg: dict[str, Any] = {
"name": "MeshCore Messages",
"name": "Messages",
"unique_id": f"meshcore_{nid}_messages",
"device": device,
"state_topic": f"{prefix}/{nid}/events/message",
+23
View File
@@ -0,0 +1,23 @@
import logging
import aiosqlite
logger = logging.getLogger(__name__)
async def migrate(conn: aiosqlite.Connection) -> None:
"""Add muted column to channels table."""
table_check = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='channels'"
)
if not await table_check.fetchone():
await conn.commit()
return
cursor = await conn.execute("PRAGMA table_info(channels)")
columns = {row[1] for row in await cursor.fetchall()}
if "muted" not in columns:
await conn.execute("ALTER TABLE channels ADD COLUMN muted INTEGER DEFAULT 0")
await conn.commit()
+1
View File
@@ -346,6 +346,7 @@ class Channel(BaseModel):
)
last_read_at: int | None = None # Server-side read state tracking
favorite: bool = False
muted: bool = False
class ChannelMessageCounts(BaseModel):
+10
View File
@@ -14,6 +14,7 @@ from pywebpush import WebPushException
from app.push.send import send_push
from app.push.vapid import get_vapid_private_key
from app.repository.channels import ChannelRepository
from app.repository.push_subscriptions import PushSubscriptionRepository
from app.repository.settings import AppSettingsRepository
@@ -102,6 +103,15 @@ class PushManager:
if state_key not in push_conversations:
return
# Skip muted channels
if data.get("type") == "CHAN" and data.get("conversation_key"):
try:
ch = await ChannelRepository.get_by_key(data["conversation_key"])
if ch and ch.muted:
return
except Exception:
logger.debug("Push dispatch: failed to check channel mute state", exc_info=True)
try:
subs = await PushSubscriptionRepository.get_all()
except Exception:
+15 -2
View File
@@ -28,7 +28,7 @@ class ChannelRepository:
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted
FROM channels
WHERE key = ?
""",
@@ -45,6 +45,7 @@ class ChannelRepository:
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
muted=bool(row["muted"]),
)
return None
@@ -53,7 +54,7 @@ class ChannelRepository:
async with db.readonly() as conn:
async with conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite, muted
FROM channels
ORDER BY name
"""
@@ -69,6 +70,7 @@ class ChannelRepository:
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
muted=bool(row["muted"]),
)
for row in rows
]
@@ -84,6 +86,17 @@ class ChannelRepository:
rowcount = cursor.rowcount
return rowcount > 0
@staticmethod
async def set_muted(key: str, value: bool) -> bool:
"""Set or clear the muted flag for a channel. Returns True if row was found."""
async with db.tx() as conn:
async with conn.execute(
"UPDATE channels SET muted = ? WHERE key = ?",
(1 if value else 0, key.upper()),
) as cursor:
rowcount = cursor.rowcount
return rowcount > 0
@staticmethod
async def delete(key: str) -> None:
"""Delete a channel by key."""
+1
View File
@@ -701,6 +701,7 @@ class MessageRepository:
JOIN channels c ON m.conversation_key = c.key
WHERE m.type = 'CHAN' AND m.outgoing = 0
AND m.received_at > COALESCE(c.last_read_at, 0)
AND COALESCE(c.muted, 0) = 0
{blocked_sql}
GROUP BY m.conversation_key
""",
+28
View File
@@ -94,6 +94,15 @@ class FavoriteToggleResponse(BaseModel):
favorite: bool
class MuteChannelRequest(BaseModel):
key: str = Field(description="Channel key to toggle mute status")
class MuteChannelToggleResponse(BaseModel):
key: str
muted: bool
class TrackedTelemetryRequest(BaseModel):
public_key: str = Field(description="Public key of the repeater to toggle tracking")
@@ -260,6 +269,25 @@ async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
@router.post("/muted-channels/toggle", response_model=MuteChannelToggleResponse)
async def toggle_muted_channel(request: MuteChannelRequest) -> MuteChannelToggleResponse:
"""Toggle a channel's muted status."""
channel = await ChannelRepository.get_by_key(request.key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
new_value = not channel.muted
await ChannelRepository.set_muted(request.key, new_value)
logger.info("%s channel mute: %s", "Muted" if new_value else "Unmuted", request.key[:12])
refreshed = await ChannelRepository.get_by_key(request.key)
if refreshed:
from app.websocket import broadcast_event
broadcast_event("channel", refreshed.model_dump())
return MuteChannelToggleResponse(key=request.key, muted=new_value)
@router.post("/blocked-keys/toggle", response_model=AppSettings)
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
"""Toggle a public key's blocked status."""
+186 -1194
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -66,7 +66,7 @@
"tailwindcss": "^3.4.19",
"typescript": "^5.6.3",
"typescript-eslint": "^8.19.0",
"vite": "^6.0.3",
"vitest": "^2.1.0"
"vite": "^6.4.2",
"vitest": "^4.1.4"
}
}
+29 -1
View File
@@ -25,7 +25,13 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { usePush } from './contexts/PushSubscriptionContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
import type {
BulkCreateHashtagChannelsResult,
Channel,
Conversation,
Message,
RawPacket,
} from './types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
import { shouldAutoFocusInput } from './utils/autoFocusInput';
@@ -207,6 +213,12 @@ export function App() {
removeConversationMessagesRef.current(conversationId),
});
// Keep channels in a ref for WS callback mute filtering
const channelsRef = useRef<Channel[]>([]);
useEffect(() => {
channelsRef.current = channels;
}, [channels]);
const handleToggleFavorite = useCallback(
async (type: 'channel' | 'contact', id: string) => {
// Optimistically toggle the favorite flag
@@ -343,6 +355,20 @@ export function App() {
useFaviconBadge(unreadCounts, mentions, channels);
useUnreadTitle(unreadCounts, contacts, channels);
const handleToggleMute = useCallback(
async (key: string) => {
setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c)));
try {
await api.toggleChannelMute(key);
await refreshUnreads();
} catch {
setChannels((prev) => prev.map((c) => (c.key === key ? { ...c, muted: !c.muted } : c)));
toast.error('Failed to update mute');
}
},
[setChannels, refreshUnreads]
);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
setChannelUnreadMarker(null);
@@ -408,6 +434,7 @@ export function App() {
setContacts,
blockedKeysRef,
blockedNamesRef,
channelsRef,
activeConversationRef,
observeMessage,
recordMessageEvent,
@@ -586,6 +613,7 @@ export function App() {
onRunTracePath: api.requestRadioTrace,
onPathDiscovery: handlePathDiscovery,
onToggleFavorite: handleToggleFavorite,
onToggleMute: handleToggleMute,
onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
+6
View File
@@ -343,6 +343,12 @@ export const api = {
body: JSON.stringify({ type, id }),
}),
toggleChannelMute: (key: string) =>
fetchJson<{ key: string; muted: boolean }>('/settings/muted-channels/toggle', {
method: 'POST',
body: JSON.stringify({ key }),
}),
// Fanout
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
createFanoutConfig: (config: {
+109 -77
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { Bell, BellOff, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
@@ -32,6 +32,7 @@ interface ChatHeaderProps {
onTogglePush?: () => void;
onOpenPushSettings?: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onToggleMute?: (key: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
onDeleteChannel: (key: string) => void;
@@ -57,6 +58,7 @@ export function ChatHeader({
onTogglePush,
onOpenPushSettings,
onToggleFavorite,
onToggleMute,
onSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride,
onDeleteChannel,
@@ -313,95 +315,125 @@ export function ChatHeader({
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
)}
{(notificationsSupported || pushSupported) && !activeContactIsRoomServer && (
<div className="relative" ref={notifDropdownRef}>
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setNotifDropdownOpen((v) => !v)}
title="Notification settings"
aria-label="Notification settings"
aria-expanded={notifDropdownOpen}
>
<Bell
className={cn(
'h-4 w-4',
notificationsEnabled || pushEnabledForConversation
? 'text-primary'
: 'text-muted-foreground'
{(notificationsSupported ||
pushSupported ||
(conversation.type === 'channel' && onToggleMute)) &&
!activeContactIsRoomServer && (
<div className="relative" ref={notifDropdownRef}>
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setNotifDropdownOpen((v) => !v)}
title="Notification settings"
aria-label="Notification settings"
aria-expanded={notifDropdownOpen}
>
{activeChannel?.muted ? (
<BellOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
) : (
<Bell
className={cn(
'h-4 w-4',
notificationsEnabled || pushEnabledForConversation
? 'text-primary'
: 'text-muted-foreground'
)}
fill={
notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'
}
aria-hidden="true"
/>
)}
fill={notificationsEnabled || pushEnabledForConversation ? 'currentColor' : 'none'}
aria-hidden="true"
/>
</button>
{notifDropdownOpen && (
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
{notificationsSupported && (
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
type="checkbox"
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
checked={notificationsEnabled}
disabled={notificationsPermission === 'denied'}
onChange={onToggleNotifications}
/>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground block leading-tight">
Desktop notifications (legacy)
</span>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
{notificationsPermission === 'denied'
? 'Blocked by browser — check site permissions'
: 'Alerts while this tab is open'}
</span>
</div>
</label>
)}
{pushSupported && onTogglePush && (
<>
</button>
{notifDropdownOpen && (
<div className="absolute right-[-4.5rem] sm:right-0 top-full z-50 mt-1 w-[calc(100vw-2rem)] sm:w-72 max-w-72 rounded-md border border-border bg-popover p-3 shadow-lg space-y-3">
{notificationsSupported && (
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
type="checkbox"
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
checked={!!pushEnabledForConversation}
onChange={onTogglePush}
checked={notificationsEnabled}
disabled={notificationsPermission === 'denied'}
onChange={onToggleNotifications}
/>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground block leading-tight">
Web Push (beta testing)
Desktop notifications (legacy)
</span>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
{pushSubscribed
? 'Alerts even when the browser is closed'
: 'Alerts even when the browser is closed. Requires HTTPS.'}
{notificationsPermission === 'denied'
? 'Blocked by browser — check site permissions'
: 'Alerts while this tab is open'}
</span>
</div>
</label>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
All notification types require a trusted HTTPS context. Depending on your
browser, a snakeoil certificate may not be sufficient.
</span>
{onOpenPushSettings && (
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
Manage Web Push enabled devices in{' '}
<button
type="button"
onClick={() => {
setNotifDropdownOpen(false);
onOpenPushSettings();
}}
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Settings &rarr; Local
</button>
.
</p>
)}
</>
)}
</div>
)}
</div>
)}
)}
{pushSupported && onTogglePush && (
<>
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
type="checkbox"
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
checked={!!pushEnabledForConversation}
onChange={onTogglePush}
/>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground block leading-tight">
Web Push (beta testing)
</span>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
{pushSubscribed
? 'Alerts even when the browser is closed'
: 'Alerts even when the browser is closed. Requires HTTPS.'}
</span>
</div>
</label>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
All notification types require a trusted HTTPS context. Depending on your
browser, a snakeoil certificate may not be sufficient.
</span>
{onOpenPushSettings && (
<p className="text-xs text-muted-foreground leading-snug mt-1.5">
Manage Web Push enabled devices in{' '}
<button
type="button"
onClick={() => {
setNotifDropdownOpen(false);
onOpenPushSettings();
}}
className="text-primary hover:underline transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Settings &rarr; Local
</button>
.
</p>
)}
</>
)}
{conversation.type === 'channel' && onToggleMute && (
<>
<hr className="border-border" />
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
type="checkbox"
className="mt-0.5 accent-primary h-4 w-4 shrink-0"
checked={!!activeChannel?.muted}
onChange={() => onToggleMute(conversation.id)}
/>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground block leading-tight">
Mute channel
</span>
<span className="text-xs text-muted-foreground leading-snug block mt-0.5">
Hide unread counts and suppress all notifications
</span>
</div>
</label>
</>
)}
</div>
)}
</div>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -62,6 +62,7 @@ interface ConversationPaneProps {
) => Promise<RadioTraceResponse>;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
onToggleMute: (key: string) => Promise<void>;
onDeleteContact: (publicKey: string) => Promise<void>;
onDeleteChannel: (key: string) => Promise<void>;
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
@@ -143,6 +144,7 @@ export function ConversationPane({
onRunTracePath,
onPathDiscovery,
onToggleFavorite,
onToggleMute,
onDeleteContact,
onDeleteChannel,
onSetChannelFloodScopeOverride,
@@ -307,6 +309,7 @@ export function ConversationPane({
onPathDiscovery={onPathDiscovery}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onToggleMute={onToggleMute}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
onDeleteChannel={onDeleteChannel}
+32 -17
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
BellOff,
Cable,
ChartNetwork,
CheckCheck,
@@ -49,6 +50,7 @@ type ConversationRow = {
unreadCount: number;
isMention: boolean;
notificationsEnabled: boolean;
muted?: boolean;
contact?: Contact;
};
@@ -250,6 +252,10 @@ export function Sidebar({
if (isPublicChannelKey(a.key)) return -1;
if (isPublicChannelKey(b.key)) return 1;
// Muted channels always sort to the bottom
if (a.muted && !b.muted) return 1;
if (!a.muted && b.muted) return -1;
if (sectionSortOrders.channels === 'recent') {
const timeA = getLastMessageTime('channel', a.key);
const timeB = getLastMessageTime('channel', b.key);
@@ -530,9 +536,10 @@ export function Sidebar({
type: 'channel',
id: channel.key,
name: channel.name,
unreadCount: getUnreadCount('channel', channel.key),
isMention: hasMention('channel', channel.key),
unreadCount: channel.muted ? 0 : getUnreadCount('channel', channel.key),
isMention: channel.muted ? false : hasMention('channel', channel.key),
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
muted: channel.muted,
});
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
@@ -584,23 +591,31 @@ export function Sidebar({
)}
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span>
<span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
{row.muted ? (
<span aria-label="Channel muted" title="Channel muted">
<BellOff className="h-3.5 w-3.5 text-muted-foreground" />
</span>
)}
{row.unreadCount > 0 && (
<span
className={cn(
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
highlightUnread
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
) : (
<>
{row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
</span>
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
{row.unreadCount > 0 && (
<span
className={cn(
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
highlightUnread
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
)}
</>
)}
</span>
</div>
@@ -1194,7 +1194,7 @@ function MqttHaConfigEditor({
<details className="group">
<summary className="text-sm font-medium text-foreground cursor-pointer select-none flex items-center gap-1">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-0 -rotate-90" />
Published Topic Summary
Published topic summary
</summary>
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
+17 -7
View File
@@ -35,6 +35,7 @@ interface UseRealtimeAppStateArgs {
setContacts: Dispatch<SetStateAction<Contact[]>>;
blockedKeysRef: MutableRefObject<string[]>;
blockedNamesRef: MutableRefObject<string[]>;
channelsRef: MutableRefObject<Channel[]>;
activeConversationRef: MutableRefObject<Conversation | null>;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
recordMessageEvent: (args: {
@@ -94,6 +95,7 @@ export function useRealtimeAppState({
setContacts,
blockedKeysRef,
blockedNamesRef,
channelsRef,
activeConversationRef,
observeMessage,
recordMessageEvent,
@@ -191,16 +193,24 @@ export function useRealtimeAppState({
return;
}
const isMutedChannel =
msg.type === 'CHAN' &&
!!msg.conversation_key &&
channelsRef.current.some((c) => c.key === msg.conversation_key && c.muted);
const { added: isNewMessage, activeConversation: isForActiveConversation } =
observeMessage(msg);
recordMessageEvent({
msg,
activeConversation: isForActiveConversation,
isNewMessage,
hasMention: checkMention(msg.text),
});
if (!msg.outgoing && isNewMessage) {
if (!isMutedChannel) {
recordMessageEvent({
msg,
activeConversation: isForActiveConversation,
isNewMessage,
hasMention: checkMention(msg.text),
});
}
if (!msg.outgoing && isNewMessage && !isMutedChannel) {
notifyIncomingMessage?.(msg);
}
},
+1
View File
@@ -70,6 +70,7 @@ describe('fetchJson (via api methods)', () => {
});
function installMockFetch() {
mockFetch.mockReset();
global.fetch = mockFetch;
}
@@ -18,6 +18,7 @@ describe('BulkAddChannelResultModal', () => {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
},
{
key: 'BB'.repeat(16),
@@ -26,6 +27,7 @@ describe('BulkAddChannelResultModal', () => {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
},
],
existing_count: 3,
@@ -15,7 +15,15 @@ import { api } from '../api';
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
return {
key,
name,
is_hashtag: isHashtag,
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
}
function makeDetail(channel: Channel): ChannelDetail {
@@ -7,7 +7,15 @@ import { CONTACT_TYPE_ROOM } from '../types';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
return {
key,
name,
is_hashtag: isHashtag,
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
}
const noop = () => {};
@@ -90,6 +90,7 @@ const channel: Channel = {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
const message: Message = {
@@ -142,6 +143,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
throw new Error('unused');
}),
onToggleFavorite: vi.fn(async () => {}),
onToggleMute: vi.fn(async () => {}),
onDeleteContact: vi.fn(async () => {}),
onDeleteChannel: vi.fn(async () => {}),
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
+1 -1
View File
@@ -1057,7 +1057,7 @@ describe('SettingsFanoutSection', () => {
selectCreateIntegration('Home Assistant MQTT Discovery');
confirmCreateIntegration();
expect(await screen.findByText('Published Topic Summary')).toBeInTheDocument();
expect(await screen.findByText('Published topic summary')).toBeInTheDocument();
fireEvent.click(await screen.findByLabelText(/Alice/));
fireEvent.click(await screen.findByLabelText(/Repeater One/));
@@ -24,6 +24,7 @@ const BOT_CHANNEL: Channel = {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
const BOT_PACKET: RawPacket = {
@@ -15,6 +15,7 @@ const TEST_CHANNEL: Channel = {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
const COLLIDING_TEST_CHANNEL: Channel = {
+1
View File
@@ -42,6 +42,7 @@ const defaultProps = {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
},
],
onNavigateToMessage: vi.fn(),
+1
View File
@@ -14,6 +14,7 @@ function makeChannel(key: string, name: string): Channel {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
}
+3
View File
@@ -194,6 +194,7 @@ describe('resolveChannelFromHashToken', () => {
on_radio: true,
last_read_at: null,
favorite: false,
muted: false,
},
{
key: '11111111111111111111111111111111',
@@ -202,6 +203,7 @@ describe('resolveChannelFromHashToken', () => {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
},
{
key: '22222222222222222222222222222222',
@@ -210,6 +212,7 @@ describe('resolveChannelFromHashToken', () => {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
},
];
@@ -186,6 +186,7 @@ describe('useContactsAndChannels', () => {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
},
],
existing_count: 1,
@@ -34,6 +34,7 @@ const publicChannel: Channel = {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
const sentMessage: Message = {
@@ -11,6 +11,7 @@ const publicChannel: Channel = {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
+9 -1
View File
@@ -14,7 +14,15 @@ import type { Channel, Contact } from '../types';
import { getStateKey } from '../utils/conversationState';
function makeChannel(key: string, favorite = false): Channel {
return { key, name: key, is_hashtag: false, on_radio: false, last_read_at: null, favorite };
return {
key,
name: key,
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite,
muted: false,
};
}
function makeContact(publicKey: string, favorite = false): Contact {
@@ -29,6 +29,7 @@ const publicChannel: Channel = {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
const incomingDm: Message = {
@@ -65,6 +66,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
fetchAllContacts: vi.fn(async () => [] as Contact[]),
setContacts,
blockedKeysRef: { current: [] as string[] },
channelsRef: { current: [publicChannel] },
blockedNamesRef: { current: [] as string[] },
activeConversationRef: { current: null as Conversation | null },
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
@@ -36,6 +36,7 @@ function makeChannel(key: string, name: string): Channel {
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
}
+1
View File
@@ -223,6 +223,7 @@ export interface Channel {
path_hash_mode_override?: number | null;
last_read_at: number | null;
favorite: boolean;
muted: boolean;
}
export interface ChannelMessageCounts {
+2 -1
View File
@@ -81,7 +81,8 @@ echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[build]${NC} "
cd "$REPO_ROOT/frontend"
npx --quiet tsc 2>&1 && npx --quiet vite build --logLevel error 2>&1
npx --quiet tsc 2>&1
npx --quiet vite build --logLevel error 2>&1
echo -e "${GREEN}Passed!${NC}"
echo -e "${GREEN}=== Phase 2 complete ===${NC}"
+1 -1
View File
@@ -2,4 +2,4 @@
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
# ``applied == LATEST - starting_version`` so only this constant needs to
# change, not every individual assertion.
LATEST_SCHEMA_VERSION = 58
LATEST_SCHEMA_VERSION = 59