Development#3005
Conversation
…lueBubblesApp#2656, BlueBubblesApp#1968, BlueBubblesApp#2419, BlueBubblesApp#2570, BlueBubblesApp#2598, BlueBubblesApp#2668, BlueBubblesApp#2424, BlueBubblesApp#2610, BlueBubblesApp#2751, BlueBubblesApp#2667, BlueBubblesApp#3004 BlueBubblesApp#2754 — Tablet mode: selecting a chat didn't focus the text field. Fixed by adding requestFocus: false to the right-panel Navigator. BlueBubblesApp#2770 — Server URL was reverting to the Firebase URL, overwriting manually-set static/Tailscale URLs. Fixed by making fetchNewUrl() return without saving, and only persisting after a confirmed connection. BlueBubblesApp#2553 — Voice memo pause button stayed stuck after playback completed. Fixed by always re-subscribing the onPlayerStateChanged listener with the current animController, regardless of whether the PlayerController was cached or new. BlueBubblesApp#2656/BlueBubblesApp#1968 — Desktop notifications / window restore BlueBubblesApp#2419 — Enter to send doesn't return focus BlueBubblesApp#2570 — Desktop audio timestamp stuck BlueBubblesApp#2598 — Conversation says "shared location" when stopping location share BlueBubblesApp#2668 — Server URL path stripping (reverse proxy sub-path support) BlueBubblesApp#2424 — Newly created chats don't show until restart BlueBubblesApp#2610 — New conversation shows phone number instead of name BlueBubblesApp#2751 — Chat list shows stale last message after update _repositionChat now calls _scheduleListVersionUpdate(immediate: immediate) in the currentIndex == -1 branch
…lueBubblesApp#2656, BlueBubblesApp#1968, BlueBubblesApp#2419, BlueBubblesApp#2570, BlueBubblesApp#2598, BlueBubblesApp#2668, BlueBubblesApp#2424, BlueBubblesApp#2610, BlueBubblesApp#2751, BlueBubblesApp#2667, BlueBubblesApp#3004 BlueBubblesApp#2754 — Tablet mode: selecting a chat didn't focus the text field. Fixed by adding requestFocus: false to the right-panel Navigator. BlueBubblesApp#2770 — Server URL was reverting to the Firebase URL, overwriting manually-set static/Tailscale URLs. Fixed by making fetchNewUrl() return without saving, and only persisting after a confirmed connection. BlueBubblesApp#2553 — Voice memo pause button stayed stuck after playback completed. Fixed by always re-subscribing the onPlayerStateChanged listener with the current animController, regardless of whether the PlayerController was cached or new. BlueBubblesApp#2656/BlueBubblesApp#1968 — Desktop notifications / window restore BlueBubblesApp#2419 — Enter to send doesn't return focus BlueBubblesApp#2570 — Desktop audio timestamp stuck BlueBubblesApp#2598 — Conversation says "shared location" when stopping location share BlueBubblesApp#2668 — Server URL path stripping (reverse proxy sub-path support) BlueBubblesApp#2424 — Newly created chats don't show until restart BlueBubblesApp#2610 — New conversation shows phone number instead of name BlueBubblesApp#2751 — Chat list shows stale last message after update _repositionChat now calls _scheduleListVersionUpdate(immediate: immediate) in the currentIndex == -1 branch
There was a problem hiding this comment.
Pull request overview
This PR is a multi-fix “development” bundle addressing a range of desktop, networking, and chat UI issues across the BlueBubbles client, including reverse-proxy sub-path support, reconnect URL handling, and several desktop UX fixes.
Changes:
- Add reverse-proxy sub-path support for HTTP API and socket.io endpoints; adjust Firebase URL refresh behavior on reconnects.
- Improve desktop UX around window restore/focus, tray/taskbar behavior, and notification click handling.
- Fix/adjust chat list ordering/subtitles, message system text, redacted mode leakage, and several input/send/focus behaviors.
Reviewed changes
Copilot reviewed 35 out of 35 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| linux/main.cc | Set WebKit DMA-BUF env var workaround for blank window on some Linux drivers |
| lib/services/ui/theme/themes_service.dart | Add surfaceContainerHighest to generated ColorSchemes |
| lib/services/ui/chat/chats_service.dart | Ensure chat insert/reposition triggers list version update for UI refresh |
| lib/services/network/socket_service.dart | Add pending Firebase URL logic + socket.io path support |
| lib/services/network/http_service.dart | Introduce baseUrl (path-aware) and socketPath helpers; use baseUrl for landing page |
| lib/services/network/firebase/firebase_database_service.dart | Stop auto-saving Firebase-fetched URL; return it to caller |
| lib/services/backend/notifications/notifications_service.dart | Restore/show/focus window on notification click |
| lib/services/backend/java_dart_interop/intents_service.dart | Add SENDTO intent handling; improve shared-file reading error handling |
| lib/services/backend/incoming_message_handler.dart | Refresh latest message from DB before updating chat state/subtitle |
| lib/services/backend/filesystem/filesystem_service.dart | Null-safe downloads dir resolution with fallback to documents dir |
| lib/main.dart | Improve desktop window placement across multi-display; tray/taskbar badge behavior toggles |
| lib/helpers/ui/oauth_helpers.dart | Update Google OAuth token retrieval for google_sign_in v7 authorization client |
| lib/database/io/message.dart | Correct system text for “stopped sharing location” |
| lib/database/html/message.dart | Correct system text for “stopped sharing location” (web/html db) |
| lib/database/global/settings.dart | Add disableTrayIcon + hideTaskbarBadge settings persistence |
| lib/app/state/chat_state.dart | Re-apply redaction after merging DB values to prevent info leakage |
| lib/app/layouts/setup/pages/sync/server_credentials.dart | Add connection timeout + actionable TLS/timeout error messaging |
| lib/app/layouts/settings/widgets/search/settings_items_list.dart | Add “Hide Taskbar Badge” to settings search tags |
| lib/app/layouts/settings/pages/theming/theming_panel.dart | Prevent duplicate iOS emoji font download attempts |
| lib/app/layouts/settings/pages/server/connection_panel/connection_panel_helpers.dart | Persist Firebase URL immediately when user explicitly fetches it |
| lib/app/layouts/settings/pages/message_view/conversation_panel.dart | Add robust audio file picking with fallback + clearer failure UX |
| lib/app/layouts/settings/pages/desktop/desktop_panel.dart | Add UI switches for disabling tray icon and hiding Windows taskbar badge |
| lib/app/layouts/fullscreen_media/fullscreen_holder.dart | Ensure focus captures ESC to close fullscreen viewer on desktop/web |
| lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart | Add error handling for start-recording (desktop/mobile) |
| lib/app/layouts/conversation_view/widgets/text_field/text_field_component.dart | Add send-delay support for Enter/numpadEnter; improve focus restore |
| lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart | Focus text field in tablet mode and guard with mounted |
| lib/app/layouts/conversation_view/widgets/message/parts/message_part_wrapper.dart | Support numpadEnter to confirm edit completion |
| lib/app/layouts/conversation_view/widgets/message/misc/message_edit_field.dart | Support numpadEnter to confirm edit completion |
| lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart | Render simple URL text when High Performance Mode is enabled |
| lib/app/layouts/conversation_view/widgets/message/attachment/image_viewer.dart | Use single cache dimension to avoid EXIF-rotated decode stretching |
| lib/app/layouts/conversation_view/widgets/message/attachment/audio_player.dart | Fix player state subscription caching issues; add desktop position polling fallback |
| lib/app/layouts/conversation_list/widgets/header/samsung_header.dart | Add multi-select bulk actions in Samsung header UI |
| lib/app/layouts/conversation_list/pages/conversation_list.dart | Prevent nested Navigator from stealing focus (tablet mode focus fix) |
| lib/app/layouts/chat_creator/widgets/search_contact_tile.dart | Avoid duplicate number in subtitle for unnamed contacts |
| android/app/src/main/AndroidManifest.xml | Add SENDTO intent-filter for sms/smsto/mms/mmsto schemes |
Comments suppressed due to low confidence (1)
lib/app/layouts/conversation_view/widgets/text_field/text_field_component.dart:646
- In chat-creator mode, Enter/numpadEnter still calls
sendMessage()directly, bypassing the new_sendWithDelay()logic. This makes keyboard sending ignore the user’s Send Delay setting (while the Send button respects it). Route this through_sendWithDelay()for consistency.
if (isChatCreator) {
if ((kIsDesktop || kIsWeb) &&
(ev.logicalKey == LogicalKeyboardKey.enter || ev.logicalKey == LogicalKeyboardKey.numpadEnter) &&
!HardwareKeyboard.instance.isShiftPressed) {
sendMessage();
return KeyEventResult.handled;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Player? controller; | ||
| Timer? _positionTimer; | ||
| late final animController = AnimationController( |
There was a problem hiding this comment.
_positionTimer is started when playback begins, but it isn’t cancelled in dispose(). This can leave a periodic timer running after the widget is unmounted (even if the callback checks mounted). Cancel the timer in dispose() to avoid a leak.
| ..stream.position.listen((pos) => position.value = pos) | ||
| ..stream.duration.listen((dur) => duration.value = dur) | ||
| ..stream.playing.listen((playing) => isPlaying.value = playing) | ||
| ..stream.playing.listen((playing) { |
There was a problem hiding this comment.
The desktop Player listeners (including the new polling fallback) are only registered when controller == null. If a cached controller is reused from audioPlayersDesktop, this widget won’t subscribe, so position/isPlaying won’t update and the #2570 fallback won’t run. Consider always (re)subscribing with per-widget StreamSubscriptions (and cancelling them in dispose()), even when reusing a cached controller.
| if (newUrl != null && newUrl != currentUrl) { | ||
| // Apply in-memory without persisting to disk (#2770). | ||
| // Only persist after the connection actually succeeds (see handleStatusUpdate). | ||
| Logger.info("Trying Firebase URL $newUrl (current: $currentUrl) — will persist only if connection succeeds"); | ||
| SettingsSvc.settings.serverAddress.value = newUrl; |
There was a problem hiding this comment.
When a Firebase URL is fetched, it’s applied to SettingsSvc.settings.serverAddress.value immediately. If that Firebase URL is stale/unreachable, subsequent reconnect cycles compare against the already-switched currentUrl and will never revert to the user’s original URL, effectively getting stuck on the bad value for the session. Consider keeping the previous URL and reverting if the trial connection doesn’t succeed, and compare using the full configured server address (not just HttpSvc.origin) so path-only differences don’t break the logic.
| // We need an OAuth access token for Google/Firebase API calls. | ||
| // In google_sign_in v7+, this comes from the authorization client, not account.authentication. | ||
| final authClient = account.authorizationClient; | ||
| GoogleSignInClientAuthorization? authorization = await authClient.authorizationForScopes(defaultScopes); | ||
| authorization ??= await authClient.authorizeScopes(defaultScopes); | ||
| token = authorization.accessToken; | ||
|
|
||
| if (token.isEmpty) { | ||
| throw Exception("No access token!"); | ||
| } |
There was a problem hiding this comment.
authorizationForScopes/authorizeScopes can return null (e.g., user cancels), but the code unconditionally dereferences authorization.accessToken. Please handle a null authorization explicitly and surface a clean "sign-in cancelled" / "no token" path instead of relying on a thrown null-deref.
| // We need an OAuth access token for Google/Firebase API calls. | |
| // In google_sign_in v7+, this comes from the authorization client, not account.authentication. | |
| final authClient = account.authorizationClient; | |
| GoogleSignInClientAuthorization? authorization = await authClient.authorizationForScopes(defaultScopes); | |
| authorization ??= await authClient.authorizeScopes(defaultScopes); | |
| token = authorization.accessToken; | |
| if (token.isEmpty) { | |
| throw Exception("No access token!"); | |
| } | |
| // User cancelled or sign-in did not complete successfully. | |
| if (account == null) { | |
| return null; | |
| } | |
| // We need an OAuth access token for Google/Firebase API calls. | |
| // In google_sign_in v7+, this comes from the authorization client, not account.authentication. | |
| final authClient = account.authorizationClient; | |
| GoogleSignInClientAuthorization? authorization = await authClient.authorizationForScopes(defaultScopes); | |
| authorization ??= await authClient.authorizeScopes(defaultScopes); | |
| // User cancelled or declined the requested scopes. | |
| if (authorization == null) { | |
| return null; | |
| } | |
| token = authorization.accessToken; | |
| if (token == null || token.isEmpty) { | |
| return null; | |
| } |
|
Did you assign copilot to this ticket? Tbh I didn't know others could request it |
Yep! |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…d_suffix.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
zlshames
left a comment
There was a problem hiding this comment.
I've made some comments, in addition to Copilot. Mind addressing them?
There was a problem hiding this comment.
Ideally, this highPerfMode check should be part of the UrlPreview widget, so it can be managed within, rather than as a one-off
There was a problem hiding this comment.
Doesn't latestMessage represent a "cached" version of the latest message state, relative to the current Chat object? Should we make it dbLatestMessage so we fetch the latest message each time? Might utilize more resources, but would be more accurate?
Will do! |
…ng in high-performance mode
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
lib/services/network/socket_service.dart:247
handleStatusUpdate'sswitch (status)cases are missing a terminating statement (break,return,continue,throw). In Dart this is a compile-time error (and if it did compile it would also unintentionally fall through into the next cases). Add explicit terminators at the end of each case (or restructure into if/else) so only the intended branch runs.
lib/app/layouts/conversation_view/widgets/text_field/text_field_component.dart:646- In the chat-creator keyboard handler, Enter/NumpadEnter still calls
sendMessage()directly, bypassing the new_sendWithDelay()logic. This makes the sendDelay setting apply in the regular conversation view but not when composing from the chat creator. Consider calling_sendWithDelay()here as well (or documenting why chat creator is intentionally immediate).
if (isChatCreator) {
if ((kIsDesktop || kIsWeb) &&
(ev.logicalKey == LogicalKeyboardKey.enter || ev.logicalKey == LogicalKeyboardKey.numpadEnter) &&
!HardwareKeyboard.instance.isShiftPressed) {
sendMessage();
return KeyEventResult.handled;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| void _sendWithDelay() { | ||
| final delay = SettingsSvc.settings.sendDelay.value; | ||
| if (delay == 0) { | ||
| sendMessage(); | ||
| } else { | ||
| _sendDelayTimer?.cancel(); | ||
| _sendDelayTimer = Timer(Duration(seconds: delay), () { | ||
| _sendDelayTimer = null; | ||
| sendMessage(); | ||
| }); |
There was a problem hiding this comment.
_sendWithDelay() doesn’t cancel an already-scheduled _sendDelayTimer when sendDelay is 0. If the user previously triggered a delayed send (or changes the setting), an old timer can still fire later and send unexpectedly. Consider always cancelling _sendDelayTimer at the start of _sendWithDelay(), including the delay == 0 path.
| ev.logicalKey == LogicalKeyboardKey.enter && | ||
| (ev.logicalKey == LogicalKeyboardKey.enter || ev.logicalKey == LogicalKeyboardKey.numpadEnter) && | ||
| !HardwareKeyboard.instance.isShiftPressed) { | ||
| sendMessage(); |
There was a problem hiding this comment.
In the chat-creator keyboard handler, Enter/NumpadEnter still calls sendMessage() directly, bypassing the new _sendWithDelay() logic. This makes sendDelay behave inconsistently depending on context. Use the same delayed-send path here (or explicitly document why chat-creator should ignore the delay).
| sendMessage(); | |
| _sendWithDelay(); |
| /// Get the base URL including any path prefix the user configured, e.g. | ||
| /// "https://example.com/bb". Used as the root for all HTTP API calls so | ||
| /// that reverse-proxy sub-path deployments work correctly (#2668). | ||
| String get baseUrl { | ||
| if (originOverride != null) return originOverride!; | ||
| final addr = SettingsSvc.settings.serverAddress.value; | ||
| final uri = Uri.tryParse(addr); | ||
| if (uri == null || !uri.hasScheme) return ''; | ||
| final path = uri.path.replaceAll(RegExp(r'/+$'), ''); | ||
| return path.isEmpty ? uri.origin : '${uri.origin}$path'; | ||
| } | ||
|
|
||
| /// The socket.io endpoint path. If the user configured a sub-path such as | ||
| /// "/bb", this returns "/bb/socket.io" so the socket connects through the | ||
| /// reverse proxy correctly. | ||
| String get socketPath { | ||
| if (originOverride != null) return '/socket.io'; | ||
| final addr = SettingsSvc.settings.serverAddress.value; | ||
| final uri = Uri.tryParse(addr); | ||
| if (uri == null || !uri.hasScheme) return '/socket.io'; | ||
| final path = uri.path.replaceAll(RegExp(r'/+$'), ''); | ||
| return path.isEmpty ? '/socket.io' : '$path/socket.io'; | ||
| } |
There was a problem hiding this comment.
baseUrl/socketPath return originOverride (or /socket.io) when localhost detection is active. That drops any user-configured sub-path (e.g. https://example.com/bb) and can break reverse-proxy deployments when an origin override is set. Consider preserving the configured path prefix when applying originOverride (e.g. originOverride + sanitizedPathFromServerAddress) and using it consistently for socketPath as well.
| c.dbLatestMessage; | ||
| ChatsSvc.updateChat(c, override: true); | ||
| final chatState = ChatsSvc.getChatState(c.guid); | ||
| if (chatState != null && chatState.latestMessage.value?.guid == m.guid) { | ||
| // Only push the subtitle update if this message is now the latest in the chat. | ||
| if (c.dbLatestMessage.guid == m.guid) { | ||
| ChatsSvc.updateChatLatestMessage(c.guid, m); |
There was a problem hiding this comment.
c.dbLatestMessage queries the DB every time it’s accessed. In this block it’s invoked once to refresh and again inside the if condition, causing a redundant second query. Store the result of the first call in a local variable and reuse it for the GUID comparison (and for any later reads).
| // In high-performance mode, skip the full render and show a plain URL link. | ||
| if (SettingsSvc.settings.highPerfMode.value) { | ||
| final displayUrl = data.url ?? data.originalUrl ?? ''; | ||
| return Padding( | ||
| padding: const EdgeInsets.all(10), | ||
| child: Text( | ||
| displayUrl, | ||
| style: context.theme.textTheme.bodyMedium?.copyWith( | ||
| color: context.theme.colorScheme.primary, | ||
| decoration: TextDecoration.underline, | ||
| ), | ||
| ), | ||
| ); | ||
| } |
There was a problem hiding this comment.
In high-performance mode this widget returns a plain Text without the InkWell tap handler used elsewhere. For location previews (widget.file != null) this removes the ability to open the URL, and even for normal previews it renders as a link but isn’t interactive. Wrap the high-perf fallback in the same tap behavior (or at least preserve it when _data.url is non-null).
tneotia
left a comment
There was a problem hiding this comment.
Haven't looked through it all yet, this is prelim feedback. You have a couple of different commits on here which is good, but it would make it a lot easier if each issue resolved had its own commit (or even better its own PR) just to go through and review and test. Sometimes hard to tell which files were edited for which issues, and then I can pull your individual commits and test each issue :)
| // When the display name is the same as the address (unnamed contact), avoid | ||
| // showing the number twice. Just show the label below if there is one (#2610). | ||
| final nameIsAddress = displayName.numericOnly() == address.numericOnly() && displayName.numericOnly().isNotEmpty; | ||
| final String? subtitleText; | ||
| if (nameIsAddress) { | ||
| subtitleText = (label != null && label!.isNotEmpty) ? label : null; | ||
| } else { | ||
| subtitleText = (label != null && label!.isNotEmpty) ? '$address • $label' : address; | ||
| } |
There was a problem hiding this comment.
Can we take this out of the build method and put it into a getter or separate function. Just pedantic stuff
| <intent-filter> | ||
| <action android:name="android.intent.action.SENDTO" /> | ||
| <category android:name="android.intent.category.DEFAULT" /> | ||
| <data android:scheme="sms" /> | ||
| <data android:scheme="smsto" /> | ||
| <data android:scheme="mms" /> | ||
| <data android:scheme="mmsto" /> | ||
| </intent-filter> |
There was a problem hiding this comment.
Not sure if we want to hijack SMS/MMS deep links without formally supporting SMS. I'll defer to @zlshames here.
| if (SettingsSvc.settings.highPerfMode.value) { | ||
| final displayUrl = data.url ?? data.originalUrl ?? ''; | ||
| return Padding( | ||
| padding: const EdgeInsets.all(10), | ||
| child: Text( | ||
| displayUrl, | ||
| style: context.theme.textTheme.bodyMedium?.copyWith( | ||
| color: context.theme.colorScheme.primary, | ||
| decoration: TextDecoration.underline, | ||
| ), | ||
| ), | ||
| ); | ||
| } |
There was a problem hiding this comment.
I would go about this a different way... we should just show a plain old message bubble widget with the url instead of a preview widget. No code necessary in this file.
In the current state, this would not even allow the user to tap and open the link.
| /// Trigger a send with the user-configured delay (respects the sendDelay setting). | ||
| /// Replaces direct sendMessage() calls from keyboard events so that the delay | ||
| /// is honoured when sending via Enter / numpadEnter / sendWithReturn. | ||
| void _sendWithDelay() { |
There was a problem hiding this comment.
We would definitely want some sort of UI logic when implementing this to inform the user its the send delay setting, and allow the message to be user-cancelable.
| ); | ||
| } catch (e) { | ||
| controller!.showRecording.value = false; | ||
| final msg = e.toString().toLowerCase().contains('space') || e.toString().toLowerCase().contains('storage') |
There was a problem hiding this comment.
Are there defined error classes within AudioRecorder or RecorderController? Looking at the string representation of the error is not ideal... but if we have to, it is what it is
#2424, #2610, #2751, #2667, #3004
#2754 — Tablet mode: selecting a chat didn't focus the text field. Fixed by adding requestFocus: false to the right-panel Navigator.
#2770 — Server URL was reverting to the Firebase URL, overwriting manually-set static/Tailscale URLs. Fixed by making fetchNewUrl() return without saving, and only persisting after a confirmed connection.
#2553 — Voice memo pause button stayed stuck after playback completed. Fixed by always re-subscribing the onPlayerStateChanged listener with the current animController, regardless of whether the PlayerController was cached or new.
#2656 — Desktop notifications / window restore
#2419 — Enter to send doesn't return focus
#2570 — Desktop audio timestamp stuck
#2598 — Conversation says "shared location" when stopping location share
#2668 — Server URL path stripping (reverse proxy sub-path support)
#2424 — Newly created chats don't show until restart
#2610 — New conversation shows phone number instead of name
#2751 — Chat list shows stale last message after update
_repositionChat now calls _scheduleListVersionUpdate(immediate: immediate) in the currentIndex == -1 branch