Skip to content

fix: don't hide group messages when the participant list is stale - #3062

Open
blue-archon wants to merge 1 commit into
BlueBubblesApp:masterfrom
blue-archon:pr-bugfix
Open

fix: don't hide group messages when the participant list is stale#3062
blue-archon wants to merge 1 commit into
BlueBubblesApp:masterfrom
blue-archon:pr-bugfix

Conversation

@blue-archon

@blue-archon blue-archon commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Problem

getMessagesAsync drops any message whose sender does not match a known participant. That is correct for a 1:1 chat, but in a group it removes valid messages whenever the local participant list is stale (for example mid-sync), which shows up as an infinite loading spinner or missing messages.

Fix

Guard the filter to 1:1 chats only: chatStyle == 43 identifies an iMessage group, with participants.length > 1 as a fallback for SMS groups. chatStyle is threaded from Chat through ChatInterface to ChatActions.

Comment thread lib/services/backend/actions/chat_actions.dart Outdated
@blue-archon

Copy link
Copy Markdown
Contributor Author

I can't reproduce the original rows anymore so I can't say with certainty whether the bare form came from chat.db itself or something created locally through another path. The scenario was two rapid messages from the same sender, the second resolved to a handle the exact lookup missed, and the notification showed Unknown, and the fallback was written against that incident.

It only runs when the exact address+service lookup has already missed. If the bare form never occurs it never matches and behavior is identical to today, just one extra indexed query on the miss path. If it does occur from whatever source (server payload variance, SMS relay entries, a locally created row), it links to the canonical handle instead of creating an orphaned duplicate. So worst case it's inert, best case it prevents the Unknown sender case.

@zlshames

zlshames commented Jul 7, 2026

Copy link
Copy Markdown
Member

I can't reproduce the original rows anymore so I can't say with certainty whether the bare form came from chat.db itself or something created locally through another path. The scenario was two rapid messages from the same sender, the second resolved to a handle the exact lookup missed, and the notification showed Unknown, and the fallback was written against that incident.

It only runs when the exact address+service lookup has already missed. If the bare form never occurs it never matches and behavior is identical to today, just one extra indexed query on the miss path. If it does occur from whatever source (server payload variance, SMS relay entries, a locally created row), it links to the canonical handle instead of creating an orphaned duplicate. So worst case it's inert, best case it prevents the Unknown sender case.

i wonder if this was moreso just a race case between when the contact relationship was loaded in vs. when it wasn't. But interesting anecdote. Not sure if i want to approve all of the changes just yet. Or maybe there's a slightly better way to implement the changes that is resolving it at the root. I'll have to investigate some more.

@blue-archon

Copy link
Copy Markdown
Contributor Author

Pushed a small follow-up to this branch (b3e86cd82) that hardens the normalizer this PR adds.

During testing we hit a crash coming out of Chat.fromMap / Attachment.fromMap on live socket new/updated-message events: type 'Null' is not a subtype of type 'Object' in type cast. The cause is the .cast<String, Object>() at the call sites. On a JSON-decoded payload with a genuinely null field, the resulting CastMap re-casts that value to the non-nullable Object on every access, so both the jsonEncode fast path and the raw.entries fallback throw before the recursive normalize ever runs. One null field aborts the whole parse and drops that chat/attachment for the event. Incremental sync backfills it subsequently, but the parse shouldn't fail in the first place.

The fix rebuilds the fallback map key by key with a guarded per-value read, so a null can't take down the normalize. Since it's the exact path this PR introduces, it seemed to belong here rather than as a separate change. It showed up a handful of times over a couple days of normal use on desktop and Android before the changes.

@zlshames

zlshames commented Jul 13, 2026

Copy link
Copy Markdown
Member

For the most part, i think this looks good. The main change that concerns me is in _hydrateChat. Now, any group chat automatically has their full participant list re-fetched for any incoming message (not from me). I don't think this is scalable, and I don't love that as the solution for whatever problem you're solving.

That's the main thing I'd revert or change. I just dont think it makes sense right now. At some point it might be, but right now, I'm not so sure. There are also a few small things I might want to change a little bit:

  1. chat_actions.dart's new +/- prefix fallback (addr.startsWith('+') ? addr.substring(1) : '+$addr') assumes a phone-number-shaped address; applied to an email address it produces a nonsensical +user@example.com query. Harmless, but maybe just a little bit sloppy. Maybe guard it with a quick @ check for emails.
  2. message_summary_info.dart has two now-redundant ! operators flagged by the analyzer.

Also, there are some conflicts now with the development branch

@blue-archon

Copy link
Copy Markdown
Contributor Author

Reworked per your review:

  • _hydrateChat no longer re-fetches the participant list on every incoming group message — only when the sender's handle isn't already resolvable locally. Keeps the linking fix, drops the per-message round-trip.
  • Guarded the +/- fallback with an @ check so it skips email addresses.
  • Removed the two redundant ! in message_summary_info.dart.
  • Rebased onto current development to clear the conflicts.

Also folded in a related fix I hit while validating the above: the participant-add de-duped only on originalROWID, so a sender handle with a null/mismatched originalROWID could add the same number to a chat twice (a phantom extra group member). It now de-dupes on address + service and collapses any pre-existing duplicate.

blue-archon added a commit to blue-archon/bluebubbles-app that referenced this pull request Jul 15, 2026
… email guard, lint

Per zlshames' review on BlueBubblesApp#3062:
- _hydrateChat no longer re-fetches the full participant list on every incoming
  group message; only re-fetch when the sender's handle isn't already resolvable
  locally (skips the per-message server round-trip that doesn't scale)
- guard the +/- prefix handle fallback with an @ check so it doesn't build a
  nonsensical +user@example.com query for email addresses
- drop two redundant ! operators in message_summary_info.dart

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@zlshames zlshames left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are some additional things to resolve:

1. deepNormalizeJson can silently corrupt legitimate string content (lib/utils/deep_map_normalize.dart:848-889)

The fallback path (triggered exactly when jsonEncode(raw) throws. For instance, the CastMap-with-null case this utility was written to handle) rebuilds the map key-by-key and calls deepNormalizeJson(value) on every value, including plain-text strings. For a String value, the function unconditionally attempts jsonDecode(raw):

if (raw is String) {
  try {
    return deepNormalizeJson(jsonDecode(raw));
  } catch (_) {
    return raw;
  }
}

This is applied to fields like AttributedBody.string, which is the message text. Any message body that happens to be valid JSON on its own (a purely numeric text like "1234", or literally "true"/"null"/"false") will be silently converted from a String to an int/bool/null. Since AttributedBody.string is typed final String string;, assigning a non-string value via json["string"] ?? "" will throw a runtime TypeError on that message. For example, the exact null-containing-map scenario this PR targets is also the scenario that can now crash message parsing for ordinary numeric-text messages. Worth scoping the JSON-string-reinterpretation to fields that are actually expected to be JSON-encoded strings, rather than applying it to every string value found while walking a map.

2. Reintroduces the "mislinked recipient as sender" bug for fromMe messages (lib/services/backend/actions/chat_actions.dart, new else if branch)

The existing code deliberately guards handle resolution with:

final bool hasSenderHandle = !(inputMessage.isFromMe ?? false) && (inputMessage.handleId ?? 0) != 0;
if (inputMessage.handle == null && hasSenderHandle) { ... }
else if (inputMessage.handle != null) { ... }

with a comment explaining hasSenderHandle exists specifically because "in 1:1 chats, [it] mislinked the RECIPIENT's handle as the message's sender." This PR adds a third branch:

} else if (inputMessage.handleId != null && inputMessage.handleId! > 0) {
  final handleQuery = handleBox.query(Handle_.originalROWID.equals(inputMessage.handleId!)).build();
  ...
  handleToLink = handleQuery.findFirst();
}

This branch is reachable when inputMessage.handle == null and hasSenderHandle is false, which includes the case isFromMe == true with a non-zero handleId. It performs the handle lookup without checking isFromMe, so an outgoing message that happens to carry a non-zero handleId (plausible, since that's precisely the historical failure mode called out in the adjacent comment) can get handleToLink set and later assigned via dbMessage.handleRelation.target = handleToLink, reintroducing the bug the hasSenderHandle guard exists to prevent. This branch should also require hasSenderHandle (or explicitly !inputMessage.isFromMe!).

Other minor things I think makes sense to change:

  • incoming_message_handler.dart adds a private _chatIsGroup(Chat chat) (style == 43 || handles.length > 1 || participants.length > 1) that duplicates Chat.isGroup (chat.dart:768, style == 43 || handles.length > 1) with a slightly different definition (adds the participants.length > 1 check). Reuse chat.isGroup instead.
  • deepNormalizeJson's "fast path" does a full jsonEncode + jsonDecode round-trip, and this is invoked again at every nesting level by the various fromMap/fromJson factories (each calls asStringDynamicMapRequired on its own sub-map). For deeply nested structures (e.g. messageSummaryInfo.editedContent.values) this is quadratic-ish rework, though likely immaterial given payload sizes.
  • deep_map_normalize.dart exposes normalizeMethodChannelArguments which appears unused in this diff. Can you confirm it's wired up somewhere (Android method channel handlers) or drop it if it's dead code introduced ahead of need.

I am a bit concerned about the performance impact of the json encoding round-trip. The object sizes usually aren't too large, but it still could add jank if done alongside any UI rendering.

@blue-archon

Copy link
Copy Markdown
Contributor Author

Addressed as follows:

  1. deepNormalizeJson: It no longer reinterprets string values as JSON. It does a single guarded pass that rebuilds only Maps and Lists and returns every scalar (including Strings) untouched, so a numeric or bool-like body like "1234" stays a String. That also drops the jsonEncode/jsonDecode round-trip you flagged, so the per-nesting-level re-encoding is gone. The CastMap null-field guard is preserved.
  2. fromMe branch: removed it. Once you require hasSenderHandle (or !isFromMe) on that branch, its condition is only reachable when isFromMe is true, so guarding it makes it dead. Removing it keeps the recipient from being linked as the sender.
  3. _chatIsGroup: switched to chat.isGroup.
  4. normalizeMethodChannelArguments: it was dead (no callers), removed.
  5. +/- prefix fallback: I couldn't reproduce a bare no-plus entry, so dropped the fallback for now. If it does turn out to occur can re-assess

@zlshames

Copy link
Copy Markdown
Member

Looks like we missed one thing:

if (dbChat != null) {
  inputMessage.chat.target = dbChat;
  // Fallback: resolve sender against this chat's linked participants
  if (handleToLink == null && inputMessage.handleId != null && inputMessage.handleId! > 0) {
    handleToLink = List<Handle>.from(dbChat.handles).firstWhereOrNull((h) => h.originalROWID == inputMessage.handleId);
  }
  ...
}

This runs unconditionally with no hasSenderHandle/isFromMe check. If a fromMe message carries a non-zero handleId (the exact historical failure mode called out in the comment right above hasSenderHandle), and that ID happens to match a participant already linked to the chat (in a 1:1, that's the recipient), handleToLink gets set here and is later assigned via dbMessage.handleRelation.target = handleToLink (line ~378). That mislinks the recipient as the sender on an outgoing message. The exact bug the guard exists to prevent, just via a second code path that survived two rounds of review untouched.

@blue-archon

Copy link
Copy Markdown
Contributor Author

Added the same !isFromMe guard to the dbChat participant fallback so it matches the hasSenderHandle branch above; a from-me message with a non-zero handleId now skips it instead of resolving to the recipient.

@blue-archon blue-archon changed the title fix: resolve unknown sender in group notifications and infinite loading spinner fix: don't hide group messages when the participant list is stale Jul 25, 2026
@blue-archon

Copy link
Copy Markdown
Contributor Author

Reduced this to just the group-message-visibility fix. development now carries hasSenderHandle, so the handle-linking and normalizer changes from the earlier version are no longer needed and I have dropped them. This leaves only the getMessagesAsync filter guard.

blue-archon added a commit to blue-archon/bluebubbles-app that referenced this pull request Jul 26, 2026
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062).

Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jjoelj
jjoelj changed the base branch from development to master July 27, 2026 03:15
blue-archon added a commit to blue-archon/bluebubbles-app that referenced this pull request Aug 3, 2026
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062).

Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
blue-archon added a commit to blue-archon/bluebubbles-app that referenced this pull request Aug 4, 2026
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062).

Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
getMessagesAsync drops any message whose sender does not match a known participant. That is correct for a 1:1 chat, but in a group it removes valid messages whenever the local participant list is stale (for example mid-sync), which shows up as an infinite loading spinner or missing messages. Guard the filter to 1:1 chats only: chatStyle == 43 identifies an iMessage group, with participants.length > 1 as a fallback for SMS groups. chatStyle is threaded from Chat through ChatInterface to ChatActions.
blue-archon added a commit to blue-archon/bluebubbles-app that referenced this pull request Aug 12, 2026
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062).

Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@blue-archon

Copy link
Copy Markdown
Contributor Author

Rebased onto v2.1.0. I believe the earlier review points are addressed: shrunk to just the getMessagesAsync group filter, since hasSenderHandle covers the unknown-sender half upstream. Ready for another look.

blue-archon added a commit to blue-archon/bluebubbles-app that referenced this pull request Aug 14, 2026
getMessagesAsync filtered out any message whose sender did not match a known participant — correct for 1:1 chats, but in a group it drops valid messages whenever the local participant list is stale, which surfaces as the infinite loading spinner / missing group messages (upstream BlueBubblesApp#3062).

Guard the filter to 1:1 chats only (chatStyle == 43 => iMessage group; participants.length > 1 as an SMS-group fallback), threading chatStyle through Chat -> ChatInterface -> ChatActions. v2.0.0 already carries the unknown-sender half of BlueBubblesApp#3062 (hasSenderHandle + sole-participant fallback), so only this half needed re-applying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants