Skip to content

Commit 6e4e557

Browse files
author
Clement Guyon
committed
feat(search): validate mxid profile via API before displaying external contact
1 parent 6e5836d commit 6e4e557

File tree

4 files changed

+466
-38
lines changed

4 files changed

+466
-38
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import 'package:fluffychat/pages/new_private_chat/widget/expansion_contact_list_tile.dart';
2+
import 'package:fluffychat/pages/new_private_chat/widget/no_contacts_found.dart';
3+
import '../../base/core_robot.dart';
4+
import '../../base/test_base.dart';
5+
import '../../help/soft_assertion_helper.dart';
6+
import '../../robots/contact_list_robot.dart';
7+
import '../../robots/home_robot.dart';
8+
import '../../robots/search_robot.dart';
9+
import '../../scenarios/contact_scenario.dart';
10+
11+
void main() {
12+
TestBase().runPatrolTest(
13+
description: 'Search external contact by Matrix ID validates profile',
14+
test: ($) async {
15+
final s = SoftAssertHelper();
16+
17+
const currentAccount = String.fromEnvironment('CurrentAccount');
18+
19+
// Navigate to Contacts tab
20+
await HomeRobot($).gotoContactListScreen();
21+
22+
// --- Case 1: search a non-existent mxid ---
23+
const nonExistentMxid =
24+
'@nonexistent_test_user_xyz_000:fake.server.invalid';
25+
await ContactScenario($).enterSearchText(nonExistentMxid);
26+
27+
// Wait for the profile lookup to finish (loading → result)
28+
await CoreRobot($).waitForEitherVisible(
29+
$: $,
30+
first: $(NoContactsFound),
31+
second: $(ExpansionContactListTile),
32+
timeout: const Duration(seconds: 30),
33+
);
34+
await $.pumpAndTrySettle();
35+
36+
// "No Results" should be shown for a non-existent mxid
37+
s.softAssertEquals(
38+
$(NoContactsFound).exists || (SearchRobot($).getNoResultIcon()).exists,
39+
true,
40+
'Expected "No Results" to be shown for non-existent mxid, but it was not',
41+
);
42+
43+
// No contact tile should be displayed
44+
s.softAssertEquals(
45+
$(ExpansionContactListTile).exists,
46+
false,
47+
'Expected no contact tile for non-existent mxid, but one was found',
48+
);
49+
50+
// --- Case 2: search a valid mxid (current account) ---
51+
await SearchRobot($).deleteSearchPhrase();
52+
await ContactScenario($).enterSearchText(currentAccount);
53+
54+
// Wait for the profile lookup to finish
55+
await CoreRobot($).waitForEitherVisible(
56+
$: $,
57+
first: $(NoContactsFound),
58+
second: $(ExpansionContactListTile),
59+
timeout: const Duration(seconds: 30),
60+
);
61+
await $.pumpAndTrySettle();
62+
63+
// "No Results" should NOT be shown — a contact tile should be displayed
64+
s.softAssertEquals(
65+
$(NoContactsFound).exists,
66+
false,
67+
'Expected profile info to be shown for valid mxid, but got "No Results"',
68+
);
69+
70+
// A contact list item should be visible (external contact tile)
71+
final contacts = await ContactListRobot($).getListOfContact();
72+
s.softAssertEquals(
73+
contacts.isNotEmpty || $(ExpansionContactListTile).exists,
74+
true,
75+
'Expected a contact tile to be rendered for valid mxid',
76+
);
77+
78+
s.verifyAll();
79+
},
80+
);
81+
}

lib/pages/contacts_tab/contacts_tab_body_view.dart

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'package:fluffychat/widgets/contacts_warning_banner/contacts_warning_bann
1818
import 'package:fluffychat/widgets/sliver_expandable_list.dart';
1919
import 'package:flutter/material.dart';
2020
import 'package:fluffychat/generated/l10n/app_localizations.dart';
21+
import 'package:matrix/matrix.dart';
2122

2223
class ContactsTabBodyView extends StatelessWidget {
2324
final ContactsTabController controller;
@@ -268,7 +269,7 @@ class _SliverContactsList extends StatelessWidget {
268269
}
269270
}
270271

271-
class _SilverExternalContact extends StatelessWidget {
272+
class _SilverExternalContact extends StatefulWidget {
272273
final ContactsTabController controller;
273274
final PresentationContact externalContact;
274275

@@ -277,24 +278,93 @@ class _SilverExternalContact extends StatelessWidget {
277278
required this.externalContact,
278279
});
279280

281+
@override
282+
State<_SilverExternalContact> createState() => _SilverExternalContactState();
283+
}
284+
285+
class _SilverExternalContactState extends State<_SilverExternalContact> {
286+
Future<CachedProfileInformation>? _profileFuture;
287+
String? _currentMatrixId;
288+
289+
@override
290+
void didChangeDependencies() {
291+
super.didChangeDependencies();
292+
_fetchProfileIfNeeded();
293+
}
294+
295+
@override
296+
void didUpdateWidget(covariant _SilverExternalContact oldWidget) {
297+
super.didUpdateWidget(oldWidget);
298+
if (oldWidget.externalContact.matrixId != widget.externalContact.matrixId) {
299+
_fetchProfileIfNeeded();
300+
}
301+
}
302+
303+
void _fetchProfileIfNeeded() {
304+
final matrixId = widget.externalContact.matrixId;
305+
if (matrixId == null) {
306+
_currentMatrixId = null;
307+
_profileFuture = null;
308+
return;
309+
}
310+
if (_currentMatrixId != matrixId) {
311+
_currentMatrixId = matrixId;
312+
_profileFuture = widget.controller.client.getUserProfile(
313+
matrixId,
314+
maxCacheAge: Duration.zero,
315+
);
316+
}
317+
}
318+
280319
@override
281320
Widget build(BuildContext context) {
282-
return SliverToBoxAdapter(
283-
child: Padding(
284-
padding: const EdgeInsets.symmetric(
285-
horizontal: ContactsTabViewStyle.padding,
286-
),
287-
child: ExpansionContactListTile(
288-
contact: externalContact,
289-
highlightKeyword: controller.textEditingController.text,
290-
enableInvitation: controller.supportInvitation(),
291-
onContactTap: () => controller.onContactTap(
292-
context: context,
293-
path: 'rooms',
294-
contact: externalContact,
321+
return FutureBuilder<CachedProfileInformation>(
322+
future: _profileFuture,
323+
builder: (context, snapshot) {
324+
if (snapshot.connectionState == ConnectionState.waiting) {
325+
return const SliverToBoxAdapter(child: LoadingContactWidget());
326+
}
327+
328+
if (snapshot.hasError || !snapshot.hasData) {
329+
return SliverToBoxAdapter(
330+
child: Padding(
331+
padding: const EdgeInsets.only(
332+
left: ContactsTabViewStyle.padding,
333+
top: ContactsTabViewStyle.padding,
334+
),
335+
child: NoContactsFound(
336+
keyword: widget.controller.textEditingController.text,
337+
),
338+
),
339+
);
340+
}
341+
342+
final profile = snapshot.data!;
343+
final validatedContact = PresentationContact(
344+
matrixId: widget.externalContact.matrixId,
345+
displayName:
346+
profile.displayname ?? widget.externalContact.displayName,
347+
type: widget.externalContact.type,
348+
);
349+
350+
return SliverToBoxAdapter(
351+
child: Padding(
352+
padding: const EdgeInsets.symmetric(
353+
horizontal: ContactsTabViewStyle.padding,
354+
),
355+
child: ExpansionContactListTile(
356+
contact: validatedContact,
357+
highlightKeyword: widget.controller.textEditingController.text,
358+
enableInvitation: widget.controller.supportInvitation(),
359+
onContactTap: () => widget.controller.onContactTap(
360+
context: context,
361+
path: 'rooms',
362+
contact: validatedContact,
363+
),
364+
),
295365
),
296-
),
297-
),
366+
);
367+
},
298368
);
299369
}
300370
}

lib/pages/search/search_external_contact.dart

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,102 @@ import 'package:fluffychat/pages/search/search.dart';
44
import 'package:fluffychat/pages/search/search_external_contact_style.dart';
55
import 'package:fluffychat/presentation/model/contact/presentation_contact.dart';
66
import 'package:fluffychat/presentation/model/search/presentation_search.dart';
7+
import 'package:fluffychat/widgets/matrix.dart';
8+
import 'package:fluffychat/widgets/search/empty_search_widget.dart';
9+
import 'package:fluffychat/widgets/twake_components/twake_loading/center_loading_indicator.dart';
710
import 'package:flutter/material.dart' hide SearchController;
11+
import 'package:matrix/matrix.dart';
812

9-
class SearchExternalContactWidget extends StatelessWidget {
13+
class SearchExternalContactWidget extends StatefulWidget {
1014
const SearchExternalContactWidget({
1115
super.key,
1216
required this.keyword,
1317
required this.searchController,
18+
@visibleForTesting this.clientForTesting,
1419
});
1520

1621
final String keyword;
1722
final SearchController searchController;
23+
final Client? clientForTesting;
24+
25+
@override
26+
State<SearchExternalContactWidget> createState() =>
27+
_SearchExternalContactWidgetState();
28+
}
29+
30+
class _SearchExternalContactWidgetState
31+
extends State<SearchExternalContactWidget> {
32+
Future<CachedProfileInformation>? _profileFuture;
33+
String? _currentKeyword;
34+
35+
@override
36+
void didChangeDependencies() {
37+
super.didChangeDependencies();
38+
_fetchProfileIfNeeded();
39+
}
40+
41+
@override
42+
void didUpdateWidget(covariant SearchExternalContactWidget oldWidget) {
43+
super.didUpdateWidget(oldWidget);
44+
if (oldWidget.keyword != widget.keyword) {
45+
_fetchProfileIfNeeded();
46+
}
47+
}
48+
49+
void _fetchProfileIfNeeded() {
50+
if (_currentKeyword != widget.keyword) {
51+
_currentKeyword = widget.keyword;
52+
final client = widget.clientForTesting ?? Matrix.of(context).client;
53+
_profileFuture = client.getUserProfile(
54+
widget.keyword,
55+
maxCacheAge: Duration.zero,
56+
);
57+
}
58+
}
1859

1960
@override
2061
Widget build(BuildContext context) {
21-
final newContact = PresentationContact(
22-
matrixId: keyword,
23-
displayName: keyword.substring(1),
24-
type: ContactType.external,
25-
);
62+
return FutureBuilder<CachedProfileInformation>(
63+
future: _profileFuture,
64+
builder: (context, snapshot) {
65+
if (snapshot.connectionState == ConnectionState.waiting) {
66+
return const Padding(
67+
padding: EdgeInsets.only(top: 48),
68+
child: CenterLoadingIndicator(),
69+
);
70+
}
71+
72+
if (snapshot.hasError || !snapshot.hasData) {
73+
return const EmptySearchWidget();
74+
}
2675

27-
return Padding(
28-
padding: SearchExternalContactStyle.contentPadding,
29-
child: InkWell(
30-
onTap: () {
31-
searchController.onSearchItemTap(
32-
ContactPresentationSearch(
33-
matrixId: newContact.matrixId,
34-
displayName: newContact.displayName,
76+
final profile = snapshot.data!;
77+
final newContact = PresentationContact(
78+
matrixId: widget.keyword,
79+
displayName: profile.displayname ?? widget.keyword.substring(1),
80+
type: ContactType.external,
81+
);
82+
83+
return Padding(
84+
padding: SearchExternalContactStyle.contentPadding,
85+
child: InkWell(
86+
onTap: () {
87+
widget.searchController.onSearchItemTap(
88+
ContactPresentationSearch(
89+
matrixId: newContact.matrixId,
90+
displayName: newContact.displayName,
91+
),
92+
);
93+
},
94+
borderRadius: SearchExternalContactStyle.borderRadius,
95+
child: ExpansionContactListTile(
96+
contact: newContact,
97+
highlightKeyword:
98+
widget.searchController.textEditingController.text,
3599
),
36-
);
37-
},
38-
borderRadius: SearchExternalContactStyle.borderRadius,
39-
child: ExpansionContactListTile(
40-
contact: newContact,
41-
highlightKeyword: searchController.textEditingController.text,
42-
),
43-
),
100+
),
101+
);
102+
},
44103
);
45104
}
46105
}

0 commit comments

Comments
 (0)