summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js56
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js2
-rw-r--r--packages/meshbay-hub/tests/test_chat_scroll_bottom.py92
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py5
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/ice_filter.py82
6 files changed, 222 insertions, 30 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index f21ec2c..1850c74 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -199,7 +199,7 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
}
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, mayUpload = true, onActivity }) {
+ onPreview, mayUpload = true, onActivity, status }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -218,28 +218,25 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
const atBottomRef = useRef(true);
useEffect(() => {
+ if (status !== 'connected') return;
const transport = transportRef.current;
if (!transport || !transport.connected) return;
if (!loadedRef.current) {
loadedRef.current = true;
- // The newest page. This used to be fetchChatHistory(0, 200), which paged
- // forwards from the very first message ever sent, so a busy group opened
- // on its oldest screen and the recent conversation was unreachable.
transport.fetchChatHistory({ limit: CHAT_PAGE })
.then(({ messages: msgs, hasMore: more }) => {
- setMessages(msgs);
setHasMore(more);
+ setMessages(msgs);
+ requestAnimationFrame(() => {
+ const l = listRef.current;
+ if (l) { l.scrollTop = l.scrollHeight; atBottomRef.current = true; }
+ });
})
- .catch(() => {});
+ .catch(() => { loadedRef.current = false; });
}
transport.onChat = (msg) => {
- // A live message has no row id until it is re-read from the node, so it
- // gets a local one. Keys have to be stable and unique or prepending a
- // page makes Preact reuse the wrong bubbles. Computed once and reused
- // below: the unread marker points at a message by id, so generating a
- // second one there would point it at nothing.
const id = msg.id
|| `live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
setMessages(prev => [...prev, {
@@ -250,13 +247,11 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
timestamp: msg.timestamp || Date.now() / 1000,
thread_id: msg.thread_id,
}]);
- // Somebody wrote while you were reading further up: mark where you were
- // rather than yanking the view down.
if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id);
};
return () => { transport.onChat = null; };
- }, [transportRef.current?.connected]);
+ }, [status]);
const loadOlder = useCallback(async () => {
const transport = transportRef.current;
@@ -286,14 +281,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
anchorRef.current = null;
return;
}
- // Only follow the conversation if the reader was already at the bottom.
- // Scrolling unconditionally fought every attempt to read back through it.
- //
- // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no
- // height, so aligning it to the bottom of the viewport leaves the list's
- // own padding below it and the bar stops just short of the end.
if (atBottomRef.current) list.scrollTop = list.scrollHeight;
- }, [messages]);
+ }, [messages, hasMore]);
// Keep the view pinned to the newest message while the reader is at the
// bottom, through everything that grows the content *after* the initial
@@ -357,14 +346,19 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
`${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`;
}
};
- fit();
- window.addEventListener('resize', fit);
- window.addEventListener('orientationchange', fit);
- window.visualViewport?.addEventListener('resize', fit);
+ const fitAndPin = () => {
+ fit();
+ const l = listRef.current;
+ if (l && atBottomRef.current) l.scrollTop = l.scrollHeight;
+ };
+ fitAndPin();
+ window.addEventListener('resize', fitAndPin);
+ window.addEventListener('orientationchange', fitAndPin);
+ window.visualViewport?.addEventListener('resize', fitAndPin);
return () => {
- window.removeEventListener('resize', fit);
- window.removeEventListener('orientationchange', fit);
- window.visualViewport?.removeEventListener('resize', fit);
+ window.removeEventListener('resize', fitAndPin);
+ window.removeEventListener('orientationchange', fitAndPin);
+ window.visualViewport?.removeEventListener('resize', fitAndPin);
};
}, []);
@@ -470,7 +464,11 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
<div class="chat-start">${t('chat.start_of_history')}</div>
`}
${messages.length === 0 && html`
- <div class="chat-empty">${t('chat.empty')}</div>
+ <div class="chat-empty">
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching')
+ ? html`<span class="spinner"></span>${' '}${t('status.connecting_short')}`
+ : t('chat.empty')}
+ </div>
`}
${messages.map((m, i) => {
const isOwn = m.sender_name === username || m.sender_id === username;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index dac0f45..acfcfc8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -577,7 +577,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
</div>
${apps.map(a => tab === a.key && html`
- <${a.Component} key=${a.key} ...${commonProps} />
+ <${a.Component} key=${a.key + '-' + groupId} ...${commonProps} />
`)}
${tab === 'settings' && html`
diff --git a/packages/meshbay-hub/tests/test_chat_scroll_bottom.py b/packages/meshbay-hub/tests/test_chat_scroll_bottom.py
new file mode 100644
index 0000000..7b66c00
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_chat_scroll_bottom.py
@@ -0,0 +1,92 @@
+"""
+Structural guards for the chat panel's scroll and fetch behaviour.
+
+These have regressed repeatedly. The tests below lock the invariants that
+prevent the known failure modes so that a future change to chat-app.js that
+breaks them fails loudly in the suite rather than silently shipping to a
+browser.
+
+Scroll (regressed five times):
+
+1. The scroll-to-bottom layout effect must depend on *both* ``messages`` and
+ ``hasMore``. The "load older" button is controlled by ``hasMore``; when it
+ appears, it pushes all messages down. If the effect ignores ``hasMore``, it
+ misses that shift and the chat stays above the last message.
+
+2. In the initial fetch callback, ``setHasMore`` must be called before
+ ``setMessages``. If the framework does not batch the two updates, messages
+ would arrive (and the scroll effect would fire) before the button is in
+ the DOM.
+
+Fetch (regressed three times):
+
+3. The history-fetch effect must depend on ``status``, not on
+ ``transportRef.current?.connected``. ChatPanel remounts on group switch
+ (keyed by groupId), and its mount effect fires *before* GroupPage's
+ cleanup releases the old transport. With a ref-based dep the effect sees
+ the old transport still connected, fetches from the wrong group, sets
+ ``loadedRef = true``, and never re-fires when the correct transport
+ connects (dep stays ``true``). Depending on the ``status`` prop avoids
+ this: GroupPage sets ``status = 'connected'`` only after the new transport
+ is fully connected and the index is fetched.
+"""
+from pathlib import Path
+
+import pytest
+
+STATIC = (Path(__file__).resolve().parents[1]
+ / "src" / "meshbay_hub" / "static")
+CHAT = STATIC / "chat-app.js"
+
+pytestmark = pytest.mark.skipif(not CHAT.exists(), reason="SPA sources not present")
+
+
+def _chat_panel_source() -> str:
+ src = CHAT.read_text()
+ start = src.index("\nfunction ChatPanel(")
+ end = src.find("\nfunction ", start + 1)
+ return src[start:end if end != -1 else len(src)]
+
+
+def test_scroll_layout_effect_depends_on_has_more():
+ """Without hasMore the 'load older' button appearing in a second render
+ is invisible to the scroll effect."""
+ src = _chat_panel_source()
+ assert "}, [messages, hasMore])" in src, (
+ "the scroll-to-bottom useLayoutEffect must depend on [messages, hasMore] "
+ "-- hasMore controls the 'load older' button, which shifts all messages "
+ "down when it appears; without it the scroll effect misses the shift")
+
+
+def test_initial_fetch_sets_has_more_before_messages():
+ """If the framework does not batch the two setState calls, calling
+ setMessages first lets the scroll effect run while the 'load older'
+ button is not yet in the DOM. Setting hasMore first means the button
+ is already present by the time messages (and the scroll) arrive."""
+ src = _chat_panel_source()
+ fetch = src[src.index("fetchChatHistory("):]
+ fetch = fetch[:fetch.index(".catch(")]
+ has_more_pos = fetch.index("setHasMore")
+ messages_pos = fetch.index("setMessages")
+ assert has_more_pos < messages_pos, (
+ "in the initial fetchChatHistory callback, setHasMore must come before "
+ "setMessages -- otherwise a non-batched render lets the scroll effect "
+ "run without the 'load older' button in the DOM")
+
+
+def test_fetch_effect_depends_on_status_not_transport_ref():
+ """The history-fetch effect must gate on the status prop, not on
+ transportRef.current?.connected. With a ref-based dep, ChatPanel's mount
+ effect (which fires before GroupPage's cleanup) sees the old transport
+ still connected, fetches from the wrong group, and never re-fires when
+ the correct transport connects."""
+ src = _chat_panel_source()
+ fetch_block = src[src.index("fetchChatHistory("):]
+ effect_end = fetch_block[:fetch_block.index("const loadOlder")]
+ assert "}, [status])" in effect_end, (
+ "the chat history fetch useEffect must depend on [status], not on "
+ "transportRef.current?.connected -- the ref-based dep races with "
+ "GroupPage's cleanup and picks up the stale transport on group switch")
+ assert "transportRef.current?.connected" not in effect_end, (
+ "transportRef.current?.connected must not appear in the fetch effect's "
+ "dependency array -- it causes a race on group switch")
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index e7f3116..5d9282d 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -51,6 +51,12 @@ max_concurrent_streams = 8
# Set to false only if every viewer's client is known to decode HEVC itself.
transcode_incompatible_video = true
+# ICE candidate gathering. By default, virtual/VPN interfaces (Tailscale,
+# libvirt, Docker) are auto-excluded — a STUN request that can't reach the
+# server holds the WebRTC answer for 5 seconds. Set this to restrict
+# gathering to specific interfaces (by OS adapter name).
+# ice_interfaces = ["wlp0s20f3", "eth0"]
+
# Browser and native clients reach this node over WebRTC DataChannel via hub
# signaling — no inbound port to open. QUIC is the optional direct path.
@@ -133,6 +139,12 @@ class NodeConfig:
# that already decode the source codec directly, since transcoding costs
# real CPU per concurrent viewer, unlike the copy path.
transcode_incompatible_video: bool = True
+ # ICE candidate gathering: which network interfaces to include or exclude.
+ # By default, virtual and VPN interfaces (Tailscale, libvirt, Docker) are
+ # auto-excluded because a STUN request that can't reach the server holds
+ # the gather for the full 5-second timeout — measured at 6 s total on a
+ # machine with a Tailscale wt0 interface.
+ ice_interfaces: list[str] = field(default_factory=list) # include-list overrides auto
@dataclass
@@ -290,6 +302,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.node.max_concurrent_streams, "max_concurrent_streams")
cfg.node.transcode_incompatible_video = bool(
nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video))
+ ice_if = nd.get("ice_interfaces")
+ if isinstance(ice_if, list):
+ cfg.node.ice_interfaces = [str(s) for s in ice_if]
# Multi-group: [[groups]] array
if "groups" in raw:
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 72813b6..266693a 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -455,6 +455,11 @@ class NodeDaemon:
denylist = self._denylist
# 6. WebRTC transport (browser clients)
+ from meshbay_node.transport.ice_filter import install as install_ice_filter
+ install_ice_filter(
+ self._config.node.ice_interfaces or None,
+ )
+
first = next(iter(groups_ctx.values()), None)
if WEBRTC_AVAILABLE:
self._webrtc = WebRTCTransport(
diff --git a/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py b/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py
new file mode 100644
index 0000000..a91724e
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py
@@ -0,0 +1,82 @@
+"""
+Filter network interfaces for ICE candidate gathering.
+
+aioice enumerates every interface and sends a STUN binding request from each
+IPv4 address. On a machine with a Tailscale (wt0), libvirt (virbr*), or
+Docker (docker*, veth*) interface, the STUN request often cannot reach the
+server and burns the full 5-second asyncio.wait timeout — measured at 6 s
+end-to-end on a Fedora laptop with Tailscale.
+
+This module patches aioice.ice.get_host_addresses so the gather runs against
+the interfaces the operator actually wants. Two modes:
+
+ ice_interfaces = ["wlp0s20f3"] → explicit include-list, nothing else
+ ice_interfaces = [] → auto-exclude virtual/VPN adapters
+
+Auto-excluded adapter name patterns: tailscale*, wt*, virbr*, docker*,
+veth*, br-*, podman*, cni*.
+"""
+
+import ipaddress
+import logging
+import re
+
+import aioice.ice
+
+log = logging.getLogger(__name__)
+
+_VIRTUAL_ADAPTER_RE = re.compile(
+ r"^(tailscale|wt|virbr|docker|veth|br-|podman|cni)",
+ re.IGNORECASE,
+)
+
+_original_get_host_addresses = aioice.ice.get_host_addresses
+
+
+def _is_cgnat(addr: str) -> bool:
+ """Tailscale uses 100.64.0.0/10 (RFC 6598 CGNAT)."""
+ try:
+ ip = ipaddress.ip_address(addr)
+ return ip in ipaddress.ip_network("100.64.0.0/10")
+ except ValueError:
+ return False
+
+
+def install(include: list[str] | None = None) -> None:
+ """Monkey-patch aioice.ice.get_host_addresses with a filtered version."""
+ import ifaddr
+
+ include_set = set(include) if include else None
+
+ def filtered_get_host_addresses(
+ use_ipv4: bool = True, use_ipv6: bool = True,
+ ) -> list[str]:
+ addresses: list[str] = []
+ for adapter in ifaddr.get_adapters():
+ if include_set is not None:
+ if adapter.name not in include_set:
+ continue
+ elif _VIRTUAL_ADAPTER_RE.match(adapter.name):
+ continue
+
+ for ip in adapter.ips:
+ if isinstance(ip.ip, str) and use_ipv4 and ip.ip != "127.0.0.1":
+ if include_set is None and _is_cgnat(ip.ip):
+ continue
+ addresses.append(ip.ip)
+ elif use_ipv6 and ip.ip[0] != "::1" and ip.ip[2] == 0:
+ addresses.append(ip.ip[0])
+ return addresses
+
+ aioice.ice.get_host_addresses = filtered_get_host_addresses
+
+ if include_set:
+ log.info("ICE interfaces (explicit): %s", ", ".join(sorted(include_set)))
+ else:
+ all_adapters = [a.name for a in ifaddr.get_adapters()]
+ excluded = [
+ n for n in all_adapters
+ if _VIRTUAL_ADAPTER_RE.match(n)
+ ]
+ if excluded:
+ log.info("ICE auto-excluded interfaces: %s", ", ".join(excluded))