feat(relay_chat): improve room management by adding available rooms display and joining functionality

This commit is contained in:
Ivan 2026-07-05 00:00:48 -05:00
parent 6b8c341688
commit 48cf5737c4
No known key found for this signature in database
15 changed files with 172 additions and 11 deletions

View file

@ -5157,7 +5157,8 @@ class ReticulumMeshChat:
{
"message": (
"RNode serial and Bluetooth are not available on this device. "
"On Android, the app must include usbserial4a (see MeshChatX issue #6)."
"On Android, the app must include usbserial4a and jnius "
"(see MeshChatX issue #6)."
),
},
status=422,

View file

@ -482,9 +482,22 @@ class RRCHub:
def set_auto_list(self, enabled, save=True):
with self._lock:
self.auto_list = bool(enabled)
should_list = self.auto_list and self.welcomed
if save:
self.manager.save()
self.manager._notify_change(self)
if should_list:
self._request_room_list()
def _request_room_list(self):
try:
with self._lock:
self._silent_list_pending += 1
self.send_command("/list", room=None, record_local=False)
except Exception:
with self._lock:
if self._silent_list_pending > 0:
self._silent_list_pending -= 1
def set_auto_who(self, enabled, save=True):
with self._lock:
@ -950,14 +963,7 @@ class RRCHub:
self._reconnect_attempts = 0
self.manager._on_welcome(self)
if self.auto_list:
try:
with self._lock:
self._silent_list_pending += 1
self.send_command("/list", room=None, record_local=False)
except Exception:
with self._lock:
if self._silent_list_pending > 0:
self._silent_list_pending -= 1
self._request_room_list()
def _apply_limits(self, limits):
if proto.L_MAX_NICK_BYTES in limits:

View file

@ -236,13 +236,46 @@
</span>
</li>
<li
v-if="orderedRoomsFor(hub).length === 0"
v-if="orderedRoomsFor(hub).length === 0 && availableRoomsFor(hub).length === 0"
class="px-2.5 py-1 text-xs text-sem-fg-muted"
>
{{ $t("relay_chat.no_rooms") }}
</li>
</ul>
<div v-if="availableRoomsFor(hub).length > 0" class="space-y-0.5">
<div
class="px-2.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-sem-fg-muted"
>
{{ $t("relay_chat.available_rooms") }}
</div>
<ul class="space-y-0.5">
<li
v-for="availableRoom in availableRoomsFor(hub)"
:key="availableRoom.name"
:title="availableRoom.topic || ''"
class="flex items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-sm cursor-pointer text-sem-fg-muted transition-colors hover:bg-sem-surface/60 dark:hover:bg-sem-surface/30"
@click="joinAvailableRoom(hub, availableRoom.name)"
>
<span class="flex min-w-0 items-center gap-1.5">
<MaterialDesignIcon
icon-name="pound"
class="size-3.5 shrink-0 opacity-40"
/>
<span class="truncate">{{ availableRoom.name }}</span>
</span>
<button
type="button"
:class="btnIconSm"
:title="$t('relay_chat.join')"
@click.stop="joinAvailableRoom(hub, availableRoom.name)"
>
<MaterialDesignIcon icon-name="plus" class="size-3.5" />
</button>
</li>
</ul>
</div>
<form class="flex gap-1.5" @submit.prevent="joinRoom(hub)">
<input
v-model="joinRoomName"
@ -1388,6 +1421,16 @@ export default {
}
return hub.known_rooms;
},
availableRoomsFor(hub) {
if (!hub || !hub.available_rooms || typeof hub.available_rooms !== "object") {
return [];
}
const known = new Set(this.orderedRoomsFor(hub));
return Object.entries(hub.available_rooms)
.filter(([name]) => !known.has(name))
.map(([name, topic]) => ({ name, topic }))
.sort((a, b) => a.name.localeCompare(b.name));
},
onCollapsedHubClick(hub) {
const rooms = this.orderedRoomsFor(hub);
this.selectedHubHash = hub.hub_hash;
@ -2032,13 +2075,23 @@ export default {
ToastUtils.warning(this.$t("relay_chat.room_required"));
return;
}
const joined = await this.joinRoomByName(hub, room);
if (joined) {
this.joinRoomName = "";
}
},
async joinAvailableRoom(hub, room) {
await this.joinRoomByName(hub, room);
},
async joinRoomByName(hub, room) {
try {
await window.api.post(`/api/v1/rrc/hubs/${hub.hub_hash}/rooms`, { room });
this.joinRoomName = "";
ToastUtils.success(this.$t("relay_chat.joined_room"));
await this.fetchHubs();
return true;
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
return false;
}
},
async leaveRoom() {

View file

@ -2904,6 +2904,8 @@
"dest_name": "Ziel-Aspekt (optional)",
"no_hubs": "Noch keine Hubs konfiguriert. Fügen Sie einen hinzu, um zu beginnen.",
"no_rooms": "Keine Räume beigetreten. Treten Sie einem Raum bei, um zu chatten.",
"available_rooms": "Verfügbare Räume",
"join": "Beitreten",
"no_room_selected": "Wählen Sie einen Raum, um Nachrichten anzuzeigen.",
"load_previous": "Frühere Nachrichten laden",
"rooms": "Räume",

View file

@ -2263,6 +2263,8 @@
"dest_name": "Destination Aspect (optional)",
"no_hubs": "No hubs configured yet. Add one to get started.",
"no_rooms": "No rooms joined. Join a room to start chatting.",
"available_rooms": "Available Rooms",
"join": "Join",
"no_room_selected": "Select a room to view messages.",
"load_previous": "Load earlier messages",
"rooms": "Rooms",

View file

@ -2904,6 +2904,8 @@
"dest_name": "Aspecto de destino (opcional)",
"no_hubs": "Aun no hay hubs configurados. Anade uno para empezar.",
"no_rooms": "No te has unido a ninguna sala. Unete a una para chatear.",
"available_rooms": "Salas disponibles",
"join": "Unirse",
"no_room_selected": "Selecciona una sala para ver los mensajes.",
"load_previous": "Cargar mensajes anteriores",
"rooms": "Salas",

View file

@ -2263,6 +2263,8 @@
"dest_name": "Destination Aspect (optional)",
"no_hubs": "No hubs configured yet. Add one to get started.",
"no_rooms": "No rooms joined. Join a room to start chatting.",
"available_rooms": "Available Rooms",
"join": "Join",
"no_room_selected": "Select a room to view messages.",
"load_previous": "Load earlier messages",
"rooms": "Rooms",

View file

@ -2904,6 +2904,8 @@
"dest_name": "Aspect de destination (optionnel)",
"no_hubs": "Aucun hub configure. Ajoutez-en un pour commencer.",
"no_rooms": "Aucun salon rejoint. Rejoignez un salon pour discuter.",
"available_rooms": "Salons disponibles",
"join": "Rejoindre",
"no_room_selected": "Selectionnez un salon pour voir les messages.",
"load_previous": "Charger les messages precedents",
"rooms": "Salons",

View file

@ -2904,6 +2904,8 @@
"dest_name": "Aspetto di destinazione (opzionale)",
"no_hubs": "Nessun hub configurato. Aggiungine uno per iniziare.",
"no_rooms": "Nessuna stanza. Entra in una stanza per chattare.",
"available_rooms": "Stanze disponibili",
"join": "Entra",
"no_room_selected": "Seleziona una stanza per vedere i messaggi.",
"load_previous": "Carica messaggi precedenti",
"rooms": "Stanze",

View file

@ -2904,6 +2904,8 @@
"dest_name": "Bestemmingsaspect (optioneel)",
"no_hubs": "Nog geen hubs geconfigureerd. Voeg er een toe om te beginnen.",
"no_rooms": "Geen kamers. Word lid van een kamer om te chatten.",
"available_rooms": "Beschikbare kamers",
"join": "Deelnemen",
"no_room_selected": "Selecteer een kamer om berichten te bekijken.",
"load_previous": "Eerdere berichten laden",
"rooms": "Kamers",

View file

@ -2904,6 +2904,8 @@
"dest_name": "Аспект назначения (необязательно)",
"no_hubs": "Хабы ещё не настроены. Добавьте один, чтобы начать.",
"no_rooms": "Нет комнат. Присоединитесь к комнате, чтобы общаться.",
"available_rooms": "Доступные комнаты",
"join": "Присоединиться",
"no_room_selected": "Выберите комнату для просмотра сообщений.",
"load_previous": "Загрузить более ранние сообщения",
"rooms": "Комнаты",

View file

@ -2904,6 +2904,8 @@
"dest_name": "目标方面(可选)",
"no_hubs": "尚未配置中心。添加一个以开始。",
"no_rooms": "未加入任何房间。加入房间开始聊天。",
"available_rooms": "可用房间",
"join": "加入",
"no_room_selected": "选择一个房间以查看消息。",
"load_previous": "加载更早的消息",
"rooms": "房间",

View file

@ -251,6 +251,42 @@ def test_hub_notice_updates_room_list(tmp_path):
assert hub.available_rooms == {"lobby": "Main", "random": None}
def test_welcome_sends_list_when_auto_list_enabled(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))
hub.auto_list = True
calls = []
hub.send_command = lambda text, room=None, record_local=True: calls.append(text)
hub._handle_welcome({})
assert calls == ["/list"]
def test_set_auto_list_requests_list_immediately_when_already_connected(tmp_path):
"""Enabling auto-list mid-session must not require a reconnect to take effect."""
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))
hub.welcomed = True
calls = []
hub.send_command = lambda text, room=None, record_local=True: calls.append(text)
hub.set_auto_list(True)
assert calls == ["/list"]
def test_set_auto_list_does_not_list_before_hub_is_connected(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))
calls = []
hub.send_command = lambda text, room=None, record_local=True: calls.append(text)
hub.set_auto_list(True)
assert calls == []
def test_history_is_persisted_and_reloaded(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))

View file

@ -130,6 +130,52 @@ describe("RelayChatPage.vue", () => {
expect(wrapper.vm.selectedHubHash).toBe(HUB_HASH);
});
it("shows hub rooms discovered via auto-list that have not been joined yet", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/rrc/hubs") {
return Promise.resolve({
data: { hubs: [makeHub({ available_rooms: { lobby: "Main", random: null } })] },
});
}
if (url === "/api/v1/rrc/servers") {
return Promise.resolve({ data: { hubs: [makeHostedHub()] } });
}
if (url === "/api/v1/announces") {
return Promise.resolve({ data: { announces: [makeAnnounce()] } });
}
return Promise.resolve({ data: {} });
});
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
// "lobby" is already joined (known_rooms), so only "random" is unjoined.
expect(wrapper.vm.availableRoomsFor(wrapper.vm.hubs[0])).toEqual([{ name: "random", topic: null }]);
expect(wrapper.text()).toContain("random");
});
it("joins an unjoined available room via the API when clicked", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/rrc/hubs") {
return Promise.resolve({
data: { hubs: [makeHub({ available_rooms: { random: null } })] },
});
}
if (url === "/api/v1/rrc/servers") {
return Promise.resolve({ data: { hubs: [makeHostedHub()] } });
}
if (url === "/api/v1/announces") {
return Promise.resolve({ data: { announces: [makeAnnounce()] } });
}
return Promise.resolve({ data: {} });
});
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
await wrapper.vm.joinAvailableRoom(wrapper.vm.hubs[0], "random");
expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB_HASH}/rooms`, { room: "random" });
});
it("loads messages and members when selecting a room", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));

View file

@ -0,0 +1 @@
# Optional Android dependency; stub for type checking only.