mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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) |  |  | 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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user