fix(mobile): distinguish unknown presence on identity surfaces (#7383)

<!-- Draft PR body for #7383 · fix(mobile): distinguish unknown presence
on identity surfaces · https://github.com/block/buzz/pull/7383 -->

🤖

## Summary

When mobile didn't know someone's presence — right after opening the
app, or when the relay simply had no data — DM headers, DM-list avatars
and profiles showed that person as **Offline**, with the same gray dot
as someone genuinely offline. This PR separates "we don't know" from "we
know they're offline": unknown presence now reads **Unknown** with no
dot or notch, while confirmed Online/Away/Offline look exactly as
before.

- Applies to the DM header, DM-list avatars, and the profile sheet.
Avatar geometry is unchanged — the unconfirmed indicator is hidden, not
reshaped.
- Confirmed Online/Away/Offline presentation is untouched.
- The profile exposes one presence accessibility label, with no
duplicate screen-reader announcements.

### Related issue

- Fixes: N/A. No separate issue; the related work is the stack below.
- Depends on #7382 (declared base), which supplies the presence data —
this PR is the visible half of that change.
- Draft — not requesting merge yet; the security advisory doesn't run on
this stacked base (it reviews main-based ranges).

### Testing

- Widget regressions cover unknown/online/away/offline transitions,
fixed avatar bounds, DM-list colors, and profile semantics.
- At the branch head: `just mobile-check` and the full mobile test suite
pass independently, and every broader CI gate has successful evidence
after isolated recovery runs (no monolithic local `just ci` run) —
receipts in the [exact-head evidence
comment](https://github.com/block/buzz/pull/7383#issuecomment-5573113048).
- Verification is widget-test level; no native device or simulator run
is claimed.

To see it: open a DM whose presence isn't confirmed yet (e.g. right
after a cold open) — the header and profile now read Unknown instead of
Offline.

### Screenshots

Flutter production-widget test renders — not native-device screenshots
or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Profile sheet for a person whose presence isn't confirmed yet (e.g.
right after a cold open) | ![Before: the profile reads Offline with a
gray dot — indistinguishable from actually
offline](https://github.com/user-attachments/assets/45364547-e198-45a4-ae68-777a58844009)
| ![After: the profile reads Unknown with no
dot](https://github.com/user-attachments/assets/2585d011-30b9-4df1-a0f2-6d59eec14282)
|

Confirmed Online/Away/Offline presentations are unchanged by this PR.

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d`.
After: its head `dd955a0c2ac80ada39b416a2f8b7481afb8773bf`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-30 21:35:48 -04:00
committed by GitHub
parent 965fe1dffa
commit cf5e83829a
4 changed files with 211 additions and 1 deletions

View File

@@ -4,11 +4,17 @@ import '../profile/presence_snapshot_test.dart'
show PresenceTestRelay, presenceEvent;
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'
show RenderParagraph, ScrollDirection, SemanticsAction;
show
RenderParagraph,
RenderRepaintBoundary,
ScrollDirection,
SemanticsAction;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -47,6 +53,7 @@ import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/channels/small_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/presence_cache_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/features/profile/user_profile_sheet.dart';
@@ -70,6 +77,7 @@ import 'package:buzz/shared/widgets/skeleton.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'thread_reply_refresh_cases.dart';
part 'channel_detail_page_test/presence_tests.dart';
const _channelId = '11111111-2222-4333-8444-555555555555';
const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c';
@@ -206,6 +214,7 @@ NostrEvent _edit({
Widget _buildTestable({
required List<NostrEvent> messages,
PresenceCacheNotifier? presenceCache,
List<TypingEntry> typing = const [],
Map<String, UserProfile> users = const {},
Set<String>? knownAgentPubkeys,
@@ -276,6 +285,8 @@ Widget _buildTestable({
() => userCacheNotifier ?? _FakeUserCacheNotifier(users),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
if (presenceCache != null)
presenceCacheProvider.overrideWith(() => presenceCache),
channelsProvider.overrideWith(() => fakeChannelsNotifier),
channelStarsProvider.overrideWith(_FakeChannelStarsNotifier.new),
channelMutesProvider.overrideWith(_FakeChannelMutesNotifier.new),
@@ -479,6 +490,7 @@ double? effectiveFontSizeForText(
void main() {
threadReplyRefreshTests();
presenceTests();
setUp(() async {
SharedPreferences.setMockInitialValues({});
_testPrefs = await SharedPreferences.getInstance();

View File

@@ -0,0 +1,137 @@
part of '../channel_detail_page_test.dart';
void presenceTests() {
testWidgets('DM header never substitutes offline for unknown presence', (
tester,
) async {
await _loadPresenceFonts(tester);
final cache = _PresenceFixture();
final channel = Channel(
id: _channelId,
name: 'DM',
channelType: 'dm',
description: 'Direct message',
visibility: 'private',
createdBy: 'self',
createdAt: DateTime(2025),
memberCount: 2,
participants: const ['Self', 'Alice'],
participantPubkeys: const ['self', 'alice'],
isMember: true,
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
channel: channel,
presenceCache: cache,
users: const {
'alice': UserProfile(
pubkey: 'alice',
displayName: 'Alice',
ownerPubkey: 'self',
),
},
),
);
await tester.pumpAndSettle();
final avatar = find.byKey(const ValueKey('dm-header-avatar'));
final bounds = tester.getRect(avatar);
for (final entry in <String?, String>{
null: 'Unknown',
'online': 'Online',
'away': 'Away',
'offline': 'Offline',
}.entries) {
cache.setPresence(entry.key);
await tester.pumpAndSettle();
expect(
tester
.widget<Text>(find.byKey(const ValueKey('dm-header-presence')))
.data,
entry.value,
);
expect(
tester.widget<MaskedAvatarBadge>(avatar).badge,
entry.key == null ? isNull : isNotNull,
);
expect(tester.getRect(avatar), bounds);
await _capturePresence(tester, 'dm-${entry.value}');
}
cache.setPresence(null);
await tester.pumpAndSettle();
expect(find.text('Offline'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('profile exposes one truthful presence label for every state', (
tester,
) async {
await _loadPresenceFonts(tester);
final cache = _PresenceFixture();
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
_buildTestable(
messages: const [],
presenceCache: cache,
users: const {
'alice': UserProfile(
pubkey: 'alice',
displayName: 'Alice',
ownerPubkey: 'self',
),
},
home: const Scaffold(body: UserProfileSheet(pubkey: 'alice')),
),
);
await tester.pumpAndSettle();
for (final entry in <String?, String>{
null: 'Unknown',
'online': 'Online',
'away': 'Away',
'offline': 'Offline',
}.entries) {
cache.setPresence(entry.key);
await tester.pumpAndSettle();
expect(find.text(entry.value), findsOneWidget);
expect(find.bySemanticsLabel('Presence: ${entry.value}'), findsOneWidget);
if (entry.key != 'offline') expect(find.text('Offline'), findsNothing);
await _capturePresence(tester, 'profile-${entry.value}');
}
expect(tester.takeException(), isNull);
semantics.dispose();
});
}
class _PresenceFixture extends PresenceCacheNotifier {
@override
Map<String, String> build() => {};
@override
void track(List<String> pubkeys) {}
void setPresence(String? status) => state = {'alice': ?status};
}
// Opt-in rendered evidence; ordinary test runs perform no filesystem writes.
Future<void> _loadPresenceFonts(WidgetTester tester) async {
if (Platform.environment['PRESENCE_SCREENSHOTS'] == null) return;
await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null));
await (FontLoader(
'Inter',
)..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load();
}
Future<void> _capturePresence(WidgetTester tester, String name) async {
final directory = Platform.environment['PRESENCE_SCREENSHOTS'];
if (directory == null) return;
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byType(RepaintBoundary).first,
);
await tester.runAsync(() async {
final image = await boundary.toImage();
final data = await image.toByteData(format: ui.ImageByteFormat.png);
await Directory(directory).create(recursive: true);
await File('$directory/$name.png').writeAsBytes(data!.buffer.asUint8List());
image.dispose();
});
}

View File

@@ -21,6 +21,7 @@ import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/presence_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/utils/string_utils.dart';
import 'package:buzz/shared/auth/auth.dart';
@@ -33,6 +34,8 @@ import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
part 'channels_page_test/presence_tests.dart';
void main() {
Widget buildTestable({
required List<Override> overrides,
@@ -137,6 +140,11 @@ void main() {
),
];
presenceListTests(
(overrides) => buildTestable(overrides: overrides),
testChannels,
);
testWidgets('DM tile presence observation failure hides the dot', (
tester,
) async {

View File

@@ -0,0 +1,53 @@
part of '../channels_page_test.dart';
void presenceListTests(
Widget Function(List<Override>) harness,
List<Channel> channels,
) {
testWidgets('DM list hides unconfirmed dots and paints confirmed presence', (
tester,
) async {
final cache = _ListPresenceFixture();
await tester.pumpWidget(
harness([
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
presenceCacheProvider.overrideWith(() => cache),
]),
);
await tester.pumpAndSettle();
final avatar = find.byWidgetPredicate(
(widget) => widget is AvatarImage && widget.radius == 9,
);
final dots = find.byWidgetPredicate(
(widget) =>
widget is Positioned && widget.bottom == -1 && widget.right == -1,
);
expect(dots, findsNothing);
final bounds = tester.getRect(avatar);
for (final status in ['online', 'away', 'offline']) {
cache.state = {'alice': status};
await tester.pumpAndSettle();
expect(dots, findsOneWidget);
final dot = tester.widget<Container>(
find.descendant(of: dots, matching: find.byType(Container)),
);
final theme = AppTheme.light();
expect((dot.decoration as BoxDecoration).color, switch (status) {
'online' => theme.extension<AppColors>()!.success,
'away' => theme.extension<AppColors>()!.warning,
_ => theme.colorScheme.outline,
});
expect(tester.getRect(avatar), bounds);
}
cache.state = {};
await tester.pumpAndSettle();
expect(dots, findsNothing);
});
}
class _ListPresenceFixture extends PresenceCacheNotifier {
@override
Map<String, String> build() => {};
@override
void track(List<String> pubkeys) {}
}