Files
buzz/mobile/lib/features/channels/channel.dart
Logan Johnson 9322289364 feat(mobile): follow the portable mention rules, including DMs (#8102)
🤖
## 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>
2026-10-05 18:54:49 -04:00

250 lines
7.8 KiB
Dart

import 'package:flutter/foundation.dart';
const Object _sentinel = Object();
/// Shown when a private-channel add is refused, so a missing Invite action
/// reads as a rule rather than a bug.
const privateChannelAddDeniedMessage =
'Only channel members can add people to a private channel.';
@immutable
class Channel {
final String id;
final String name;
final String channelType; // "stream", "forum", "dm"
final String visibility; // "open", "private"
final String description;
final String? topic;
final String? purpose;
final String createdBy;
final DateTime createdAt;
final int memberCount;
final DateTime? lastMessageAt;
final DateTime? archivedAt;
final List<String> participants;
final List<String> participantPubkeys;
final bool isMember;
final int? ttlSeconds;
final DateTime? ttlDeadline;
const Channel({
required this.id,
required this.name,
required this.channelType,
required this.visibility,
required this.description,
required this.createdBy,
required this.createdAt,
required this.memberCount,
this.topic,
this.purpose,
this.lastMessageAt,
this.archivedAt,
this.participants = const [],
this.participantPubkeys = const [],
this.isMember = false,
this.ttlSeconds,
this.ttlDeadline,
});
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
id: json['id'] as String,
name: json['name'] as String,
channelType: json['channel_type'] as String,
visibility: json['visibility'] as String,
description: (json['description'] as String?) ?? '',
topic: json['topic'] as String?,
purpose: json['purpose'] as String?,
createdBy: json['created_by'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
memberCount: json['member_count'] as int,
lastMessageAt: json['last_message_at'] != null
? DateTime.parse(json['last_message_at'] as String)
: null,
archivedAt: json['archived_at'] != null
? DateTime.parse(json['archived_at'] as String)
: null,
participants: (json['participants'] as List<dynamic>? ?? const [])
.cast<String>(),
participantPubkeys:
(json['participant_pubkeys'] as List<dynamic>? ?? const [])
.cast<String>(),
isMember: json['is_member'] as bool? ?? false,
ttlSeconds: json['ttl_seconds'] as int?,
ttlDeadline: json['ttl_deadline'] != null
? DateTime.parse(json['ttl_deadline'] as String)
: null,
);
bool get isEphemeral => ttlSeconds != null || ttlDeadline != null;
bool get isStream => channelType == 'stream';
bool get isForum => channelType == 'forum';
/// Streams, forums and DMs can @mention people outside them. An outside
/// person who is not added becomes a reference, which does not notify them.
/// Streams and forums ask on send. Nobody can be added to a DM, so a DM
/// does not ask.
bool get mentionsOutsidePeople => isStream || isForum || isDm;
bool get isDm => channelType == 'dm';
bool get isPrivate => visibility == 'private';
bool get canJoin => visibility == 'open' && !isArchived && !isMember && !isDm;
/// Whether [selfRole] may add *another* identity here, mirroring the relay's
/// kind:9000 authority (`validate_admin_event` + `add_member`): DMs never,
/// open channels always, private channels for any active member. Elevated
/// grants remain reserved for owners/admins. Unknown visibility fails closed.
bool canAddMembers(String? selfRole) {
if (isDm) return false;
if (visibility == 'open') return true;
if (visibility == 'private') return selfRole != null;
return false;
}
bool get isArchived => archivedAt != null;
String displayLabel({String? currentPubkey}) {
if (!isDm || participants.isEmpty) {
return name;
}
final normalizedCurrent = currentPubkey?.toLowerCase();
final labels = <String>[];
for (var index = 0; index < participants.length; index++) {
final participantPubkey = index < participantPubkeys.length
? participantPubkeys[index].toLowerCase()
: null;
if (participantPubkey != null && participantPubkey == normalizedCurrent) {
continue;
}
labels.add(participants[index]);
}
if (labels.isEmpty) {
labels.addAll(participants);
}
return labels.join(', ');
}
Channel mergeDetails(ChannelDetails details) => Channel(
id: id,
name: details.name,
channelType: details.channelType,
visibility: details.visibility,
description: details.description,
topic: details.topic,
purpose: details.purpose,
createdBy: details.createdBy,
createdAt: details.createdAt,
memberCount: memberCount,
lastMessageAt: lastMessageAt,
archivedAt: details.archivedAt,
participants: participants,
participantPubkeys: participantPubkeys,
isMember: isMember,
ttlSeconds: details.ttlSeconds,
ttlDeadline: details.ttlDeadline,
);
Channel copyWith({
String? name,
String? description,
Object? lastMessageAt = _sentinel,
Object? archivedAt = _sentinel,
int? memberCount,
bool? isMember,
}) => Channel(
id: id,
name: name ?? this.name,
channelType: channelType,
visibility: visibility,
description: description ?? this.description,
topic: topic,
purpose: purpose,
createdBy: createdBy,
createdAt: createdAt,
memberCount: memberCount ?? this.memberCount,
lastMessageAt: identical(lastMessageAt, _sentinel)
? this.lastMessageAt
: lastMessageAt as DateTime?,
archivedAt: identical(archivedAt, _sentinel)
? this.archivedAt
: archivedAt as DateTime?,
participants: participants,
participantPubkeys: participantPubkeys,
isMember: isMember ?? this.isMember,
ttlSeconds: ttlSeconds,
ttlDeadline: ttlDeadline,
);
}
@immutable
class ChannelDetails {
final String id;
final String name;
final String channelType;
final String visibility;
final String description;
final String? topic;
final String? purpose;
final String createdBy;
final DateTime createdAt;
final int memberCount;
final DateTime? archivedAt;
final int? ttlSeconds;
final DateTime? ttlDeadline;
const ChannelDetails({
required this.id,
required this.name,
required this.channelType,
required this.visibility,
required this.description,
required this.createdBy,
required this.createdAt,
required this.memberCount,
this.topic,
this.purpose,
this.archivedAt,
this.ttlSeconds,
this.ttlDeadline,
});
factory ChannelDetails.fromJson(Map<String, dynamic> json) => ChannelDetails(
id: json['id'] as String,
name: json['name'] as String,
channelType: json['channel_type'] as String,
visibility: json['visibility'] as String,
description: (json['description'] as String?) ?? '',
topic: json['topic'] as String?,
purpose: json['purpose'] as String?,
createdBy: json['created_by'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
memberCount: json['member_count'] as int,
archivedAt: json['archived_at'] != null
? DateTime.parse(json['archived_at'] as String)
: null,
ttlSeconds: json['ttl_seconds'] as int?,
ttlDeadline: json['ttl_deadline'] != null
? DateTime.parse(json['ttl_deadline'] as String)
: null,
);
factory ChannelDetails.fromChannel(Channel channel) => ChannelDetails(
id: channel.id,
name: channel.name,
channelType: channel.channelType,
visibility: channel.visibility,
description: channel.description,
topic: channel.topic,
purpose: channel.purpose,
createdBy: channel.createdBy,
createdAt: channel.createdAt,
memberCount: channel.memberCount,
archivedAt: channel.archivedAt,
ttlSeconds: channel.ttlSeconds,
ttlDeadline: channel.ttlDeadline,
);
}