mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
🤖 ## Summary The mobile app's @mention chooser now follows the same rules as the desktop app (buzz-app). The same typed text finds the same people in the same order on both apps, and sending works the same way. - **Same matching and order.** Mobile uses the shared mention rules and runs their shared test cases. A multi-word search such as `@Mary J` still searches the community directory, so it can find "Mary Jane" even when she is not in the channel. - **DMs can mention people outside the DM.** The chooser marks them "not in DM". Nobody can be added to a DM, so the message sends without a prompt. Outside people go out as references, so they are not notified. This matches buzz-app #510. - **Channels still ask.** In a stream or forum, mentioning someone outside the channel asks "Invite" or "Do nothing" before the message sends. A channel with no type tag counts as a stream. - **Sessions do not search the directory.** Only session members appear. - **A failed directory search stays visible.** The chooser shows the error and a Retry button. Before, it closed. - **Search results stay in their community.** If you switch communities while a search runs, its people and its error do not show in the new community. The search starts again there. - **A failed send check keeps the draft.** Before a message with a mention sends, the app checks who is in the channel. If that check fails, the message stays in the compose bar and a notice offers Retry. Before, nothing was sent and nothing was shown. - **Invite stays in its community.** If you switch communities while the "Invite" question is open, Invite adds nobody. ### Screenshots The real compose bar widget, rendered by Flutter's test renderer with the app's fonts and theme. The data is sample data. These are not device captures. | DM: outside person is "not in DM" | Stream: outside person is "not in channel" | |---|---| | <img width="320" alt="DM chooser" src="https://github.com/user-attachments/assets/0e884518-2386-4cd1-9f19-16661aaa194e" /> | <img width="320" alt="Stream chooser" src="https://github.com/user-attachments/assets/5395e4e1-032e-459b-98c6-0126c060c4d9" /> | | Stream: send asks first | Session: members only | Search failed: Retry | |---|---|---| | <img width="260" alt="Stream ask on send" src="https://github.com/user-attachments/assets/2b988257-3c6a-445d-acfa-fca47b9752fe" /> | <img width="260" alt="Session chooser" src="https://github.com/user-attachments/assets/e193551f-f5bf-41d9-aa29-bc8665c643f9" /> | <img width="260" alt="Search failed with Retry" src="https://github.com/user-attachments/assets/e8c4b9bb-067e-4fe9-9fdd-4e5691bc66be" /> | ### Details - `mobile/lib/shared/mentions/mention_rules.dart` holds the rules: query syntax, match tiers, sort order, Space selection of an exact unique name, stable rows while results arrive, and `p` and `mention` tags. A `p` tag notifies the person. A `mention` tag is a reference and does not notify. - `mobile/test/shared/mentions/mention-rules.fixtures.json` is an exact copy of the buzz-app fixture file. `mention_rules_test.dart` runs every case. - Known difference: desktop gives the directory to a channel with an unknown explicit type (not stream, forum, DM or session). Mobile does not. No such type exists today. ### Related issue None found. Desktop counterpart: block/buzz-app#510. #7513 also edits `send_message_provider.dart` and may conflict. ### Testing `just mobile-install mobile-check mobile-test` passes: analyze clean, 2998 tests pass on `49c44653` (before rebase). The pre-push hook ran `just mobile-check && just mobile-test` again on the rebased head `de1ca080` and passed. Each review fix was mutation-checked: removing the fix makes its new test fail. --------- Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
206 lines
5.8 KiB
Dart
206 lines
5.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:buzz/shared/mentions/mention_rules.dart';
|
|
import 'package:buzz/shared/mentions/mention_tags.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
// Portable fixtures, copied unchanged from buzz-app
|
|
// src/bundled/mentions/mention-rules.fixtures.json (mention rules v1).
|
|
final Map<String, dynamic> _fixtures =
|
|
jsonDecode(
|
|
File(
|
|
'test/shared/mentions/mention-rules.fixtures.json',
|
|
).readAsStringSync(),
|
|
)
|
|
as Map<String, dynamic>;
|
|
|
|
List<Map<String, dynamic>> _cases(String section) => [
|
|
for (final item in _fixtures[section] as List) item as Map<String, dynamic>,
|
|
];
|
|
|
|
List<Map<String, dynamic>> _tagCases(String section) => [
|
|
for (final item in (_fixtures['tags'] as Map)[section] as List)
|
|
item as Map<String, dynamic>,
|
|
];
|
|
|
|
MentionChoice _choice(Map<String, dynamic> json) {
|
|
final name = json['name'] as String;
|
|
return MentionChoice(
|
|
pubkey: json['pubkey'] as String,
|
|
name: name,
|
|
label: json['label'] as String? ?? name,
|
|
aliases: [
|
|
for (final alias in json['aliases'] as List? ?? [name]) alias as String,
|
|
],
|
|
member: json['member'] as bool,
|
|
agent: json['agent'] as bool? ?? false,
|
|
owned: json['owned'] as bool? ?? false,
|
|
managed: json['managed'] as bool? ?? false,
|
|
);
|
|
}
|
|
|
|
List<MentionChoice> _choices(Map<String, dynamic> fixture) => [
|
|
for (final choice in fixture['choices'] as List)
|
|
_choice(choice as Map<String, dynamic>),
|
|
];
|
|
|
|
void main() {
|
|
test('reads mention fixtures version 1', () {
|
|
expect(_fixtures['version'], 1);
|
|
for (final section in ['ranking', 'space', 'query', 'admission']) {
|
|
expect(_cases(section), isNotEmpty, reason: section);
|
|
}
|
|
expect(_tagCases('write'), isNotEmpty);
|
|
expect(_tagCases('read'), isNotEmpty);
|
|
});
|
|
|
|
group('tag writer fixtures', () {
|
|
for (final fixture in _tagCases('write')) {
|
|
test(fixture['name'], () {
|
|
List<String> keys(String field) => [
|
|
for (final key in fixture[field] as List) key as String,
|
|
];
|
|
final expected = fixture['expected'] as Map;
|
|
// The sender is a member in addition to `members`.
|
|
final members = {...keys('members'), 'f' * 64};
|
|
Object? written() {
|
|
try {
|
|
return {
|
|
'tags': writeMentionTags(
|
|
recipients: keys('recipients'),
|
|
references: keys('references'),
|
|
members: members,
|
|
),
|
|
};
|
|
} on MentionTagException catch (error) {
|
|
return {
|
|
'error': switch (error.error) {
|
|
MentionTagError.notMember => 'not_member',
|
|
MentionTagError.invalid => 'invalid',
|
|
MentionTagError.tooMany => 'too_many',
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
expect(written(), expected);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('tag reader fixtures', () {
|
|
for (final fixture in _tagCases('read')) {
|
|
test(fixture['name'], () {
|
|
final read = readMentionTags([
|
|
for (final tag in fixture['tags'] as List)
|
|
[for (final field in tag as List) field as String],
|
|
]);
|
|
expect({
|
|
'mentions': read.mentions,
|
|
'references': read.references,
|
|
}, fixture['expected']);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('ranking fixtures', () {
|
|
for (final fixture in _cases('ranking')) {
|
|
test(fixture['name'], () {
|
|
final history = {
|
|
for (final entry in (fixture['history'] as Map? ?? const {}).entries)
|
|
entry.key as String: entry.value as int,
|
|
};
|
|
final presence = fixture['presence'] as Map? ?? const {};
|
|
final ranked = rankMentions(
|
|
_choices(fixture),
|
|
fixture['query'] as String,
|
|
history: history,
|
|
presence: (key) => presence[key] as String? ?? 'unknown',
|
|
);
|
|
expect([for (final c in ranked) c.pubkey], fixture['expected']);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Space fixtures', () {
|
|
for (final fixture in _cases('space')) {
|
|
test(fixture['name'], () {
|
|
expect(
|
|
exactMention(_choices(fixture), fixture['query'] as String),
|
|
fixture['expected'],
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('query syntax fixtures', () {
|
|
for (final fixture in _cases('query')) {
|
|
test(fixture['name'], () {
|
|
final query = findMentionQuery(
|
|
fixture['text'] as String,
|
|
fixture['caret'] as int,
|
|
);
|
|
final expected = fixture['expected'] as Map?;
|
|
expect(
|
|
query == null
|
|
? null
|
|
: {'start': query.start, 'end': query.end, 'query': query.query},
|
|
expected,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('multi-word admission fixtures', () {
|
|
for (final fixture in _cases('admission')) {
|
|
test(fixture['name'], () {
|
|
expect(
|
|
matchesMentionQuery(fixture['query'] as String, [
|
|
for (final name in fixture['names'] as List) name as String,
|
|
]),
|
|
fixture['expected'],
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('order does not depend on input order', () {
|
|
// Review case: owned Zed, unowned Alpha, human Mary.
|
|
const zed = MentionChoice(
|
|
pubkey: 'a',
|
|
name: 'Zed',
|
|
label: 'Zed',
|
|
aliases: ['Zed'],
|
|
member: true,
|
|
agent: true,
|
|
owned: true,
|
|
);
|
|
const alpha = MentionChoice(
|
|
pubkey: 'b',
|
|
name: 'Alpha',
|
|
label: 'Alpha',
|
|
aliases: ['Alpha'],
|
|
member: true,
|
|
agent: true,
|
|
);
|
|
const mary = MentionChoice(
|
|
pubkey: 'c',
|
|
name: 'Mary',
|
|
label: 'Mary',
|
|
aliases: ['Mary'],
|
|
member: true,
|
|
);
|
|
for (final rows in [
|
|
[zed, alpha, mary],
|
|
[zed, mary, alpha],
|
|
[alpha, zed, mary],
|
|
[alpha, mary, zed],
|
|
[mary, zed, alpha],
|
|
[mary, alpha, zed],
|
|
]) {
|
|
expect(rankMentions(rows, ''), [zed, alpha, mary]);
|
|
}
|
|
});
|
|
}
|