neoagent 3.2.1-beta.11 → 3.2.1-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3032,6 +3032,71 @@ Future<void> _showMessagingAccessPolicyDialog(
3032
3032
  return result;
3033
3033
  }
3034
3034
 
3035
+ List<MessagingAccessRule> sharedSpaces() {
3036
+ final spaces = <MessagingAccessRule>[
3037
+ ...policy.sharedSpaceRules,
3038
+ ...policy.sharedMemberRules
3039
+ .where(
3040
+ (rule) =>
3041
+ (rule.spaceScope ?? '').isNotEmpty &&
3042
+ (rule.spaceValue ?? '').isNotEmpty,
3043
+ )
3044
+ .map(
3045
+ (rule) => MessagingAccessRule(
3046
+ scope: rule.spaceScope!,
3047
+ value: rule.spaceValue!,
3048
+ label: rule.spaceLabel,
3049
+ ),
3050
+ ),
3051
+ ...policy.sharedParticipationRules.map(
3052
+ (rule) => MessagingAccessRule(
3053
+ scope: rule.scope,
3054
+ value: rule.value,
3055
+ label: rule.label,
3056
+ ),
3057
+ ),
3058
+ ...catalog.suggestedTargets
3059
+ .where((target) => target.bucket == 'sharedSpaceRules')
3060
+ .map((target) => target.asRule),
3061
+ ...catalog.discoveredTargets
3062
+ .where((target) => target.bucket == 'sharedSpaceRules')
3063
+ .map((target) => target.asRule),
3064
+ ];
3065
+ return dedupeRules(spaces);
3066
+ }
3067
+
3068
+ bool allowsUntagged(MessagingAccessRule space) {
3069
+ for (final rule in policy.sharedParticipationRules) {
3070
+ if (rule.scope == space.scope && rule.value == space.value) {
3071
+ return rule.allowUntagged;
3072
+ }
3073
+ }
3074
+ return policy.defaultAllowUntaggedInShared;
3075
+ }
3076
+
3077
+ void setAllowsUntagged(
3078
+ MessagingAccessRule space,
3079
+ bool allow,
3080
+ void Function(void Function()) setLocalState,
3081
+ ) {
3082
+ setLocalState(() {
3083
+ final rules = policy.sharedParticipationRules
3084
+ .where(
3085
+ (rule) => !(rule.scope == space.scope && rule.value == space.value),
3086
+ )
3087
+ .toList();
3088
+ rules.add(
3089
+ MessagingSharedParticipationRule(
3090
+ scope: space.scope,
3091
+ value: space.value,
3092
+ label: space.label,
3093
+ allowUntagged: allow,
3094
+ ),
3095
+ );
3096
+ policy = policy.copyWith(sharedParticipationRules: rules);
3097
+ });
3098
+ }
3099
+
3035
3100
  void addRule(
3036
3101
  _MessagingRuleSelection selection,
3037
3102
  void Function(void Function()) setLocalState,
@@ -3051,6 +3116,9 @@ Future<void> _showMessagingAccessPolicyDialog(
3051
3116
  break;
3052
3117
  case 'sharedActorRules':
3053
3118
  policy = policy.copyWith(
3119
+ directPolicy: policy.directPolicy == 'disabled'
3120
+ ? 'allowlist'
3121
+ : policy.directPolicy,
3054
3122
  sharedPolicy: policy.sharedPolicy == 'disabled'
3055
3123
  ? 'allowlist'
3056
3124
  : policy.sharedPolicy,
@@ -3060,6 +3128,17 @@ Future<void> _showMessagingAccessPolicyDialog(
3060
3128
  ]),
3061
3129
  );
3062
3130
  break;
3131
+ case 'sharedMemberRules':
3132
+ policy = policy.copyWith(
3133
+ sharedPolicy: policy.sharedPolicy == 'disabled'
3134
+ ? 'allowlist'
3135
+ : policy.sharedPolicy,
3136
+ sharedMemberRules: dedupeRules(<MessagingAccessRule>[
3137
+ ...policy.sharedMemberRules,
3138
+ selection.rule,
3139
+ ]),
3140
+ );
3141
+ break;
3063
3142
  default:
3064
3143
  policy = policy.copyWith(
3065
3144
  sharedPolicy: policy.sharedPolicy == 'disabled'
@@ -3095,6 +3174,13 @@ Future<void> _showMessagingAccessPolicyDialog(
3095
3174
  .toList(growable: false),
3096
3175
  );
3097
3176
  break;
3177
+ case 'sharedMemberRules':
3178
+ policy = policy.copyWith(
3179
+ sharedMemberRules: policy.sharedMemberRules
3180
+ .where((item) => item.id != rule.id)
3181
+ .toList(growable: false),
3182
+ );
3183
+ break;
3098
3184
  default:
3099
3185
  policy = policy.copyWith(
3100
3186
  sharedSpaceRules: policy.sharedSpaceRules
@@ -3111,14 +3197,20 @@ Future<void> _showMessagingAccessPolicyDialog(
3111
3197
  return StatefulBuilder(
3112
3198
  builder: (context, setLocalState) {
3113
3199
  final capabilities = catalog.capabilities;
3200
+ final participationSpaces = sharedSpaces();
3201
+ final taggedOnlyCount = participationSpaces
3202
+ .where((space) => !allowsUntagged(space))
3203
+ .length;
3114
3204
  final summaryText = [
3115
3205
  'DMs ${policy.directPolicy}',
3116
3206
  if (capabilities.supportsSharedPolicy)
3117
3207
  'shared ${policy.sharedPolicy}',
3118
- if (capabilities.supportsMentionGate)
3119
- policy.requireMentionInShared
3120
- ? 'mentions required'
3121
- : 'mentions optional',
3208
+ if (capabilities.supportsUntaggedGroupToggle)
3209
+ !policy.defaultAllowUntaggedInShared
3210
+ ? 'untagged off by default'
3211
+ : taggedOnlyCount == 0
3212
+ ? 'social intelligence enabled'
3213
+ : '$taggedOnlyCount tagged-only',
3122
3214
  if (policy.totalRuleCount > 0) '${policy.totalRuleCount} rules',
3123
3215
  ].join(' • ');
3124
3216
 
@@ -3128,7 +3220,45 @@ Future<void> _showMessagingAccessPolicyDialog(
3128
3220
  horizontal: 24,
3129
3221
  vertical: 18,
3130
3222
  ),
3131
- title: Text('${platform.label} Access Policy'),
3223
+ shape: RoundedRectangleBorder(
3224
+ borderRadius: BorderRadius.circular(24),
3225
+ ),
3226
+ title: Row(
3227
+ children: <Widget>[
3228
+ Container(
3229
+ width: 42,
3230
+ height: 42,
3231
+ decoration: BoxDecoration(
3232
+ color: platform.accent.withValues(alpha: 0.12),
3233
+ borderRadius: BorderRadius.circular(13),
3234
+ ),
3235
+ child: Icon(
3236
+ Icons.admin_panel_settings_outlined,
3237
+ color: platform.accent,
3238
+ ),
3239
+ ),
3240
+ const SizedBox(width: 12),
3241
+ Expanded(
3242
+ child: Column(
3243
+ crossAxisAlignment: CrossAxisAlignment.start,
3244
+ children: <Widget>[
3245
+ Text(
3246
+ '${platform.label} access',
3247
+ style: TextStyle(fontWeight: FontWeight.w800),
3248
+ ),
3249
+ Text(
3250
+ 'People, groups, and response behavior',
3251
+ style: TextStyle(
3252
+ color: _textSecondary,
3253
+ fontSize: 13,
3254
+ fontWeight: FontWeight.w400,
3255
+ ),
3256
+ ),
3257
+ ],
3258
+ ),
3259
+ ),
3260
+ ],
3261
+ ),
3132
3262
  content: SizedBox(
3133
3263
  width: 760,
3134
3264
  child: SingleChildScrollView(
@@ -3146,7 +3276,10 @@ Future<void> _showMessagingAccessPolicyDialog(
3146
3276
  const SizedBox(height: 18),
3147
3277
  if (capabilities.supportsDirectPolicy)
3148
3278
  _AccessModeField(
3279
+ icon: Icons.chat_bubble_outline_rounded,
3149
3280
  label: 'Direct messages',
3281
+ description:
3282
+ 'Control who can reach the agent one-to-one.',
3150
3283
  value: policy.directPolicy,
3151
3284
  onChanged: (value) => setLocalState(() {
3152
3285
  policy = policy.copyWith(directPolicy: value);
@@ -3155,28 +3288,24 @@ Future<void> _showMessagingAccessPolicyDialog(
3155
3288
  if (capabilities.supportsSharedPolicy) ...<Widget>[
3156
3289
  const SizedBox(height: 12),
3157
3290
  _AccessModeField(
3291
+ icon: Icons.groups_2_outlined,
3158
3292
  label: 'Shared spaces',
3293
+ description:
3294
+ 'Control access in groups, channels, and rooms.',
3159
3295
  value: policy.sharedPolicy,
3160
3296
  onChanged: (value) => setLocalState(() {
3161
3297
  policy = policy.copyWith(sharedPolicy: value);
3162
3298
  }),
3163
3299
  ),
3164
3300
  ],
3165
- if (capabilities.supportsMentionGate) ...<Widget>[
3166
- const SizedBox(height: 12),
3167
- SwitchListTile(
3168
- contentPadding: EdgeInsets.zero,
3169
- title: Text('Require mention in shared spaces'),
3170
- subtitle: Text(
3171
- 'Keep channels quiet until the bot is directly mentioned.',
3172
- style: TextStyle(color: _textSecondary),
3173
- ),
3174
- value: policy.requireMentionInShared,
3175
- onChanged: (value) => setLocalState(() {
3176
- policy = policy.copyWith(
3177
- requireMentionInShared: value,
3178
- );
3179
- }),
3301
+ if (capabilities.supportsUntaggedGroupToggle) ...<Widget>[
3302
+ const SizedBox(height: 16),
3303
+ _GroupParticipationSection(
3304
+ spaces: participationSpaces,
3305
+ supportsMentionGate: capabilities.supportsMentionGate,
3306
+ allowsUntagged: allowsUntagged,
3307
+ onChanged: (space, value) =>
3308
+ setAllowsUntagged(space, value, setLocalState),
3180
3309
  ),
3181
3310
  ],
3182
3311
  const SizedBox(height: 14),
@@ -3195,7 +3324,7 @@ Future<void> _showMessagingAccessPolicyDialog(
3195
3324
  }
3196
3325
  },
3197
3326
  icon: Icon(Icons.add_rounded),
3198
- label: Text('Add Rule'),
3327
+ label: Text('Add access'),
3199
3328
  ),
3200
3329
  const SizedBox(width: 10),
3201
3330
  OutlinedButton.icon(
@@ -3207,14 +3336,15 @@ Future<void> _showMessagingAccessPolicyDialog(
3207
3336
  });
3208
3337
  },
3209
3338
  icon: Icon(Icons.travel_explore_rounded),
3210
- label: Text('Refresh Discovery'),
3339
+ label: Text('Refresh groups & people'),
3211
3340
  ),
3212
3341
  ],
3213
3342
  ),
3214
3343
  const SizedBox(height: 18),
3215
3344
  _AccessRuleSection(
3216
- title: 'Direct senders',
3217
- subtitle: 'Who can start a one-to-one conversation.',
3345
+ title: 'Direct-only rules',
3346
+ subtitle:
3347
+ 'Specific one-to-one chats that do not grant group access.',
3218
3348
  rules: policy.directRules,
3219
3349
  emptyLabel: 'No direct sender rules yet.',
3220
3350
  onRemove: (rule) =>
@@ -3223,9 +3353,9 @@ Future<void> _showMessagingAccessPolicyDialog(
3223
3353
  if (capabilities.supportsSharedPolicy) ...<Widget>[
3224
3354
  const SizedBox(height: 16),
3225
3355
  _AccessRuleSection(
3226
- title: 'Shared spaces',
3356
+ title: 'Everyone in a shared space',
3227
3357
  subtitle:
3228
- 'Which channels, groups, rooms, or servers can trigger the agent.',
3358
+ 'Allow every sender in a selected channel, group, room, or server.',
3229
3359
  rules: policy.sharedSpaceRules,
3230
3360
  emptyLabel: 'No shared-space rules yet.',
3231
3361
  onRemove: (rule) =>
@@ -3233,14 +3363,28 @@ Future<void> _showMessagingAccessPolicyDialog(
3233
3363
  ),
3234
3364
  const SizedBox(height: 16),
3235
3365
  _AccessRuleSection(
3236
- title: 'Shared actors',
3366
+ title: 'Senders everywhere',
3237
3367
  subtitle:
3238
- 'Optional extra filter for who inside allowed shared spaces can trigger the agent.',
3368
+ 'Allow these people in DMs and shared spaces. Role rules apply only in shared spaces.',
3239
3369
  rules: policy.sharedActorRules,
3240
3370
  emptyLabel: 'No shared-actor rules yet.',
3241
3371
  onRemove: (rule) =>
3242
3372
  removeRule('sharedActorRules', rule, setLocalState),
3243
3373
  ),
3374
+ const SizedBox(height: 16),
3375
+ _AccessRuleSection(
3376
+ title: 'Senders in one shared space',
3377
+ subtitle:
3378
+ 'Allow a sender only in the selected group, channel, or room.',
3379
+ rules: policy.sharedMemberRules,
3380
+ emptyLabel: 'No group-specific sender rules yet.',
3381
+ onRemove: (rule) => removeRule(
3382
+ 'sharedMemberRules',
3383
+ rule,
3384
+ setLocalState,
3385
+ ),
3386
+ showSpace: true,
3387
+ ),
3244
3388
  ],
3245
3389
  ],
3246
3390
  ),
@@ -3258,7 +3402,7 @@ Future<void> _showMessagingAccessPolicyDialog(
3258
3402
  Navigator.of(dialogContext).pop();
3259
3403
  }
3260
3404
  },
3261
- child: Text('Save Policy'),
3405
+ child: Text('Save changes'),
3262
3406
  ),
3263
3407
  ],
3264
3408
  );
@@ -3270,32 +3414,175 @@ Future<void> _showMessagingAccessPolicyDialog(
3270
3414
 
3271
3415
  class _AccessModeField extends StatelessWidget {
3272
3416
  const _AccessModeField({
3417
+ required this.icon,
3273
3418
  required this.label,
3419
+ required this.description,
3274
3420
  required this.value,
3275
3421
  required this.onChanged,
3276
3422
  });
3277
3423
 
3424
+ final IconData icon;
3278
3425
  final String label;
3426
+ final String description;
3279
3427
  final String value;
3280
3428
  final ValueChanged<String> onChanged;
3281
3429
 
3282
3430
  @override
3283
3431
  Widget build(BuildContext context) {
3284
- return InputDecorator(
3285
- decoration: InputDecoration(labelText: label),
3286
- child: DropdownButtonHideUnderline(
3287
- child: DropdownButton<String>(
3288
- value: value,
3289
- isExpanded: true,
3290
- items: const <DropdownMenuItem<String>>[
3291
- DropdownMenuItem(value: 'allowlist', child: Text('Allowlist only')),
3292
- DropdownMenuItem(value: 'open', child: Text('Open access')),
3293
- DropdownMenuItem(value: 'disabled', child: Text('Disabled')),
3294
- ],
3295
- onChanged: (next) {
3296
- if (next != null) onChanged(next);
3297
- },
3298
- ),
3432
+ return Container(
3433
+ width: double.infinity,
3434
+ padding: const EdgeInsets.all(14),
3435
+ decoration: BoxDecoration(
3436
+ color: _bgCard,
3437
+ borderRadius: BorderRadius.circular(16),
3438
+ border: Border.all(color: _borderLight),
3439
+ ),
3440
+ child: Row(
3441
+ crossAxisAlignment: CrossAxisAlignment.start,
3442
+ children: <Widget>[
3443
+ Icon(icon, color: _accent),
3444
+ const SizedBox(width: 12),
3445
+ Expanded(
3446
+ child: Column(
3447
+ crossAxisAlignment: CrossAxisAlignment.start,
3448
+ children: <Widget>[
3449
+ Text(label, style: TextStyle(fontWeight: FontWeight.w700)),
3450
+ const SizedBox(height: 3),
3451
+ Text(description, style: TextStyle(color: _textSecondary)),
3452
+ const SizedBox(height: 12),
3453
+ Wrap(
3454
+ spacing: 8,
3455
+ runSpacing: 8,
3456
+ children: <Widget>[
3457
+ ChoiceChip(
3458
+ avatar: Icon(Icons.rule_rounded, size: 18),
3459
+ label: Text('Allowlist'),
3460
+ selected: value == 'allowlist',
3461
+ onSelected: (_) => onChanged('allowlist'),
3462
+ ),
3463
+ ChoiceChip(
3464
+ avatar: Icon(Icons.public_rounded, size: 18),
3465
+ label: Text('Open'),
3466
+ selected: value == 'open',
3467
+ onSelected: (_) => onChanged('open'),
3468
+ ),
3469
+ ChoiceChip(
3470
+ avatar: Icon(Icons.block_rounded, size: 18),
3471
+ label: Text('Off'),
3472
+ selected: value == 'disabled',
3473
+ onSelected: (_) => onChanged('disabled'),
3474
+ ),
3475
+ ],
3476
+ ),
3477
+ ],
3478
+ ),
3479
+ ),
3480
+ ],
3481
+ ),
3482
+ );
3483
+ }
3484
+ }
3485
+
3486
+ class _GroupParticipationSection extends StatelessWidget {
3487
+ const _GroupParticipationSection({
3488
+ required this.spaces,
3489
+ required this.supportsMentionGate,
3490
+ required this.allowsUntagged,
3491
+ required this.onChanged,
3492
+ });
3493
+
3494
+ final List<MessagingAccessRule> spaces;
3495
+ final bool supportsMentionGate;
3496
+ final bool Function(MessagingAccessRule) allowsUntagged;
3497
+ final void Function(MessagingAccessRule, bool) onChanged;
3498
+
3499
+ @override
3500
+ Widget build(BuildContext context) {
3501
+ return Container(
3502
+ width: double.infinity,
3503
+ padding: const EdgeInsets.all(14),
3504
+ decoration: BoxDecoration(
3505
+ color: _bgCard,
3506
+ borderRadius: BorderRadius.circular(16),
3507
+ border: Border.all(color: _borderLight),
3508
+ ),
3509
+ child: Column(
3510
+ crossAxisAlignment: CrossAxisAlignment.start,
3511
+ children: <Widget>[
3512
+ Row(
3513
+ children: <Widget>[
3514
+ Icon(Icons.alternate_email_rounded, color: _accent),
3515
+ const SizedBox(width: 10),
3516
+ Expanded(
3517
+ child: Column(
3518
+ crossAxisAlignment: CrossAxisAlignment.start,
3519
+ children: <Widget>[
3520
+ Text(
3521
+ 'Untagged group messages',
3522
+ style: TextStyle(fontWeight: FontWeight.w700),
3523
+ ),
3524
+ const SizedBox(height: 3),
3525
+ Text(
3526
+ supportsMentionGate
3527
+ ? 'Tags and replies always get a response. Choose which groups may also use social intelligence for untagged messages.'
3528
+ : 'Choose which shared spaces may use social intelligence for untagged messages. This bridge may not identify tags separately.',
3529
+ style: TextStyle(color: _textSecondary, height: 1.35),
3530
+ ),
3531
+ ],
3532
+ ),
3533
+ ),
3534
+ ],
3535
+ ),
3536
+ const SizedBox(height: 12),
3537
+ if (spaces.isEmpty)
3538
+ Container(
3539
+ width: double.infinity,
3540
+ padding: const EdgeInsets.all(12),
3541
+ decoration: BoxDecoration(
3542
+ color: _bgSecondary,
3543
+ borderRadius: BorderRadius.circular(12),
3544
+ ),
3545
+ child: Text(
3546
+ 'No groups discovered yet. Refresh discovery after the agent has seen a group message.',
3547
+ style: TextStyle(color: _textMuted),
3548
+ ),
3549
+ )
3550
+ else
3551
+ ...spaces.map((space) {
3552
+ final enabled = allowsUntagged(space);
3553
+ return Container(
3554
+ margin: const EdgeInsets.only(top: 8),
3555
+ decoration: BoxDecoration(
3556
+ color: _bgSecondary,
3557
+ borderRadius: BorderRadius.circular(12),
3558
+ ),
3559
+ child: SwitchListTile(
3560
+ contentPadding: const EdgeInsets.symmetric(
3561
+ horizontal: 12,
3562
+ vertical: 2,
3563
+ ),
3564
+ secondary: Icon(
3565
+ enabled
3566
+ ? Icons.psychology_alt_outlined
3567
+ : Icons.notifications_off_outlined,
3568
+ color: enabled ? _accent : _textMuted,
3569
+ ),
3570
+ title: Text(
3571
+ space.displayLabel,
3572
+ style: TextStyle(fontWeight: FontWeight.w600),
3573
+ ),
3574
+ subtitle: Text(
3575
+ enabled
3576
+ ? 'Untagged messages use social intelligence'
3577
+ : 'Untagged messages are ignored completely',
3578
+ style: TextStyle(color: _textSecondary),
3579
+ ),
3580
+ value: enabled,
3581
+ onChanged: (value) => onChanged(space, value),
3582
+ ),
3583
+ );
3584
+ }),
3585
+ ],
3299
3586
  ),
3300
3587
  );
3301
3588
  }
@@ -3308,6 +3595,7 @@ class _AccessRuleSection extends StatelessWidget {
3308
3595
  required this.rules,
3309
3596
  required this.emptyLabel,
3310
3597
  required this.onRemove,
3598
+ this.showSpace = false,
3311
3599
  });
3312
3600
 
3313
3601
  final String title;
@@ -3315,6 +3603,7 @@ class _AccessRuleSection extends StatelessWidget {
3315
3603
  final List<MessagingAccessRule> rules;
3316
3604
  final String emptyLabel;
3317
3605
  final ValueChanged<MessagingAccessRule> onRemove;
3606
+ final bool showSpace;
3318
3607
 
3319
3608
  @override
3320
3609
  Widget build(BuildContext context) {
@@ -3341,8 +3630,14 @@ class _AccessRuleSection extends StatelessWidget {
3341
3630
  runSpacing: 8,
3342
3631
  children: rules
3343
3632
  .map((rule) {
3633
+ final spaceSuffix =
3634
+ showSpace && rule.spaceDisplayLabel.isNotEmpty
3635
+ ? ' in ${rule.spaceDisplayLabel}'
3636
+ : '';
3344
3637
  return Chip(
3345
- label: Text('${rule.scopeLabel}: ${rule.displayLabel}'),
3638
+ label: Text(
3639
+ '${rule.scopeLabel}: ${rule.displayLabel}$spaceSuffix',
3640
+ ),
3346
3641
  deleteIcon: Icon(Icons.close_rounded, size: 18),
3347
3642
  onDeleted: () => onRemove(rule),
3348
3643
  );
@@ -3387,30 +3682,47 @@ class _MessagingAccessRulePickerSheet extends StatefulWidget {
3387
3682
  class _MessagingAccessRulePickerSheetState
3388
3683
  extends State<_MessagingAccessRulePickerSheet> {
3389
3684
  late final TextEditingController _queryController;
3685
+ late final TextEditingController _valueController;
3686
+ late final TextEditingController _spaceValueController;
3390
3687
  late String _selectedBucket;
3391
3688
  late String _selectedScope;
3689
+ late String _selectedSpaceScope;
3392
3690
 
3393
3691
  @override
3394
3692
  void initState() {
3395
3693
  super.initState();
3396
3694
  _queryController = TextEditingController();
3397
- _selectedBucket = widget.catalog.capabilities.directRuleScopes.isNotEmpty
3695
+ _valueController = TextEditingController();
3696
+ _spaceValueController = TextEditingController();
3697
+ _selectedBucket =
3698
+ widget.catalog.capabilities.sharedActorRuleScopes.isNotEmpty
3699
+ ? 'sharedActorRules'
3700
+ : _directOnlyScopes().isNotEmpty
3398
3701
  ? 'directRules'
3399
3702
  : (widget.catalog.capabilities.sharedSpaceRuleScopes.isNotEmpty
3400
3703
  ? 'sharedSpaceRules'
3401
3704
  : 'sharedActorRules');
3402
- _selectedScope = widget.catalog.capabilities.directRuleScopes.isNotEmpty
3403
- ? widget.catalog.capabilities.directRuleScopes.first
3705
+ _selectedScope =
3706
+ widget.catalog.capabilities.sharedActorRuleScopes.isNotEmpty
3707
+ ? widget.catalog.capabilities.sharedActorRuleScopes.first
3708
+ : _directOnlyScopes().isNotEmpty
3709
+ ? _directOnlyScopes().first
3404
3710
  : (widget.catalog.capabilities.sharedSpaceRuleScopes.isNotEmpty
3405
3711
  ? widget.catalog.capabilities.sharedSpaceRuleScopes.first
3406
3712
  : (widget.catalog.capabilities.sharedActorRuleScopes.isNotEmpty
3407
3713
  ? widget.catalog.capabilities.sharedActorRuleScopes.first
3408
3714
  : 'chat'));
3715
+ _selectedSpaceScope =
3716
+ widget.catalog.capabilities.sharedSpaceRuleScopes.isNotEmpty
3717
+ ? widget.catalog.capabilities.sharedSpaceRuleScopes.first
3718
+ : 'chat';
3409
3719
  }
3410
3720
 
3411
3721
  @override
3412
3722
  void dispose() {
3413
3723
  _queryController.dispose();
3724
+ _valueController.dispose();
3725
+ _spaceValueController.dispose();
3414
3726
  super.dispose();
3415
3727
  }
3416
3728
 
@@ -3423,14 +3735,23 @@ class _MessagingAccessRulePickerSheetState
3423
3735
  List<String> _scopesForBucket() {
3424
3736
  switch (_selectedBucket) {
3425
3737
  case 'directRules':
3426
- return widget.catalog.capabilities.directRuleScopes;
3738
+ return _directOnlyScopes();
3427
3739
  case 'sharedActorRules':
3740
+ case 'sharedMemberRules':
3428
3741
  return widget.catalog.capabilities.sharedActorRuleScopes;
3429
3742
  default:
3430
3743
  return widget.catalog.capabilities.sharedSpaceRuleScopes;
3431
3744
  }
3432
3745
  }
3433
3746
 
3747
+ List<String> _directOnlyScopes() {
3748
+ final sharedActorScopes = widget.catalog.capabilities.sharedActorRuleScopes
3749
+ .toSet();
3750
+ return widget.catalog.capabilities.directRuleScopes
3751
+ .where((scope) => !sharedActorScopes.contains(scope))
3752
+ .toList(growable: false);
3753
+ }
3754
+
3434
3755
  void _syncSelectedScope() {
3435
3756
  final availableScopes = _scopesForBucket();
3436
3757
  if (availableScopes.isEmpty || availableScopes.contains(_selectedScope)) {
@@ -3439,24 +3760,59 @@ class _MessagingAccessRulePickerSheetState
3439
3760
  _selectedScope = availableScopes.first;
3440
3761
  }
3441
3762
 
3763
+ void _submitManualRule(BuildContext context) {
3764
+ final value = _valueController.text.trim();
3765
+ if (value.isEmpty) return;
3766
+ final isMemberRule = _selectedBucket == 'sharedMemberRules';
3767
+ final spaceValue = _spaceValueController.text.trim();
3768
+ if (isMemberRule && spaceValue.isEmpty) return;
3769
+ Navigator.of(context).pop(
3770
+ _MessagingRuleSelection(
3771
+ bucket: _selectedBucket,
3772
+ rule: MessagingAccessRule(
3773
+ scope: _selectedScope,
3774
+ value: value,
3775
+ spaceScope: isMemberRule ? _selectedSpaceScope : null,
3776
+ spaceValue: isMemberRule ? spaceValue : null,
3777
+ ),
3778
+ ),
3779
+ );
3780
+ }
3781
+
3442
3782
  @override
3443
3783
  Widget build(BuildContext context) {
3444
3784
  final availableScopes = _scopesForBucket();
3445
3785
  final query = _queryController.text.trim().toLowerCase();
3446
- final targets =
3447
- <MessagingAccessTarget>[
3448
- ...widget.catalog.suggestedTargets,
3449
- ...widget.catalog.discoveredTargets,
3450
- ]
3451
- .where((target) {
3452
- if (target.bucket != _selectedBucket) return false;
3453
- if (query.isEmpty) return true;
3454
- final haystack =
3455
- '${target.label} ${target.subtitle} ${target.scope} ${target.value}'
3456
- .toLowerCase();
3457
- return haystack.contains(query);
3458
- })
3459
- .toList(growable: false);
3786
+ final allTargets = <MessagingAccessTarget>[
3787
+ ...widget.catalog.suggestedTargets,
3788
+ ...widget.catalog.discoveredTargets,
3789
+ ];
3790
+ bool matchesQuery(MessagingAccessTarget target) {
3791
+ if (query.isEmpty) return true;
3792
+ final haystack =
3793
+ '${target.label} ${target.subtitle} ${target.scope} ${target.value}'
3794
+ .toLowerCase();
3795
+ return haystack.contains(query);
3796
+ }
3797
+
3798
+ final targets = allTargets
3799
+ .where((target) {
3800
+ if (target.bucket != _selectedBucket) return false;
3801
+ return matchesQuery(target);
3802
+ })
3803
+ .toList(growable: false);
3804
+ final memberActorTargets = allTargets
3805
+ .where(
3806
+ (target) =>
3807
+ target.bucket == 'sharedActorRules' && matchesQuery(target),
3808
+ )
3809
+ .toList(growable: false);
3810
+ final memberSpaceTargets = allTargets
3811
+ .where(
3812
+ (target) =>
3813
+ target.bucket == 'sharedSpaceRules' && matchesQuery(target),
3814
+ )
3815
+ .toList(growable: false);
3460
3816
 
3461
3817
  return Padding(
3462
3818
  padding: EdgeInsets.only(
@@ -3484,9 +3840,9 @@ class _MessagingAccessRulePickerSheetState
3484
3840
  spacing: 8,
3485
3841
  runSpacing: 8,
3486
3842
  children: <Widget>[
3487
- if (widget.catalog.capabilities.directRuleScopes.isNotEmpty)
3843
+ if (_directOnlyScopes().isNotEmpty)
3488
3844
  ChoiceChip(
3489
- label: Text('Direct'),
3845
+ label: Text('Direct only'),
3490
3846
  selected: _selectedBucket == 'directRules',
3491
3847
  onSelected: (_) => setState(() {
3492
3848
  _selectedBucket = 'directRules';
@@ -3499,7 +3855,7 @@ class _MessagingAccessRulePickerSheetState
3499
3855
  .sharedSpaceRuleScopes
3500
3856
  .isNotEmpty)
3501
3857
  ChoiceChip(
3502
- label: Text('Shared spaces'),
3858
+ label: Text('Everyone in space'),
3503
3859
  selected: _selectedBucket == 'sharedSpaceRules',
3504
3860
  onSelected: (_) => setState(() {
3505
3861
  _selectedBucket = 'sharedSpaceRules';
@@ -3512,13 +3868,31 @@ class _MessagingAccessRulePickerSheetState
3512
3868
  .sharedActorRuleScopes
3513
3869
  .isNotEmpty)
3514
3870
  ChoiceChip(
3515
- label: Text('Shared actors'),
3871
+ label: Text('Sender everywhere'),
3516
3872
  selected: _selectedBucket == 'sharedActorRules',
3517
3873
  onSelected: (_) => setState(() {
3518
3874
  _selectedBucket = 'sharedActorRules';
3519
3875
  _syncSelectedScope();
3520
3876
  }),
3521
3877
  ),
3878
+ if (widget
3879
+ .catalog
3880
+ .capabilities
3881
+ .sharedActorRuleScopes
3882
+ .isNotEmpty &&
3883
+ widget
3884
+ .catalog
3885
+ .capabilities
3886
+ .sharedSpaceRuleScopes
3887
+ .isNotEmpty)
3888
+ ChoiceChip(
3889
+ label: Text('Sender in one space'),
3890
+ selected: _selectedBucket == 'sharedMemberRules',
3891
+ onSelected: (_) => setState(() {
3892
+ _selectedBucket = 'sharedMemberRules';
3893
+ _syncSelectedScope();
3894
+ }),
3895
+ ),
3522
3896
  ],
3523
3897
  ),
3524
3898
  const SizedBox(height: 12),
@@ -3557,6 +3931,59 @@ class _MessagingAccessRulePickerSheetState
3557
3931
  }),
3558
3932
  const Divider(height: 24),
3559
3933
  ],
3934
+ if (_selectedBucket == 'sharedMemberRules' &&
3935
+ (memberActorTargets.isNotEmpty ||
3936
+ memberSpaceTargets.isNotEmpty)) ...<Widget>[
3937
+ if (memberActorTargets.isNotEmpty) ...<Widget>[
3938
+ Text(
3939
+ 'Choose a discovered sender',
3940
+ style: TextStyle(fontWeight: FontWeight.w700),
3941
+ ),
3942
+ const SizedBox(height: 8),
3943
+ Wrap(
3944
+ spacing: 8,
3945
+ runSpacing: 8,
3946
+ children: memberActorTargets
3947
+ .take(10)
3948
+ .map((target) {
3949
+ return ActionChip(
3950
+ label: Text(target.label),
3951
+ onPressed: () => setState(() {
3952
+ _selectedScope = target.scope;
3953
+ _valueController.text = target.value;
3954
+ }),
3955
+ );
3956
+ })
3957
+ .toList(growable: false),
3958
+ ),
3959
+ const SizedBox(height: 14),
3960
+ ],
3961
+ if (memberSpaceTargets.isNotEmpty) ...<Widget>[
3962
+ Text(
3963
+ 'Choose a discovered shared space',
3964
+ style: TextStyle(fontWeight: FontWeight.w700),
3965
+ ),
3966
+ const SizedBox(height: 8),
3967
+ Wrap(
3968
+ spacing: 8,
3969
+ runSpacing: 8,
3970
+ children: memberSpaceTargets
3971
+ .take(10)
3972
+ .map((target) {
3973
+ return ActionChip(
3974
+ label: Text(target.label),
3975
+ onPressed: () => setState(() {
3976
+ _selectedSpaceScope = target.scope;
3977
+ _spaceValueController.text = target.value;
3978
+ }),
3979
+ );
3980
+ })
3981
+ .toList(growable: false),
3982
+ ),
3983
+ const SizedBox(height: 14),
3984
+ ],
3985
+ const Divider(height: 10),
3986
+ ],
3560
3987
  Text('Manual entry', style: TextStyle(fontWeight: FontWeight.w700)),
3561
3988
  const SizedBox(height: 8),
3562
3989
  if (availableScopes.isNotEmpty)
@@ -3584,23 +4011,58 @@ class _MessagingAccessRulePickerSheetState
3584
4011
  ),
3585
4012
  const SizedBox(height: 12),
3586
4013
  TextField(
4014
+ controller: _valueController,
3587
4015
  decoration: InputDecoration(
3588
- labelText: 'ID / value',
4016
+ labelText: _selectedBucket == 'sharedMemberRules'
4017
+ ? 'Sender ID / value'
4018
+ : 'ID / value',
3589
4019
  helperText: widget.catalog.capabilities.manualEntryHint,
3590
4020
  ),
3591
- onSubmitted: (value) {
3592
- final trimmed = value.trim();
3593
- if (trimmed.isEmpty) return;
3594
- Navigator.of(context).pop(
3595
- _MessagingRuleSelection(
3596
- bucket: _selectedBucket,
3597
- rule: MessagingAccessRule(
3598
- scope: _selectedScope,
3599
- value: trimmed,
3600
- ),
4021
+ onSubmitted: _selectedBucket == 'sharedMemberRules'
4022
+ ? null
4023
+ : (_) => _submitManualRule(context),
4024
+ ),
4025
+ if (_selectedBucket == 'sharedMemberRules') ...<Widget>[
4026
+ const SizedBox(height: 12),
4027
+ InputDecorator(
4028
+ decoration: InputDecoration(labelText: 'Shared-space scope'),
4029
+ child: DropdownButtonHideUnderline(
4030
+ child: DropdownButton<String>(
4031
+ value: _selectedSpaceScope,
4032
+ isExpanded: true,
4033
+ items: widget.catalog.capabilities.sharedSpaceRuleScopes
4034
+ .map(
4035
+ (scope) => DropdownMenuItem<String>(
4036
+ value: scope,
4037
+ child: Text(scope.replaceAll('_', ' ')),
4038
+ ),
4039
+ )
4040
+ .toList(growable: false),
4041
+ onChanged: (value) {
4042
+ if (value != null) {
4043
+ setState(() => _selectedSpaceScope = value);
4044
+ }
4045
+ },
3601
4046
  ),
3602
- );
3603
- },
4047
+ ),
4048
+ ),
4049
+ const SizedBox(height: 12),
4050
+ TextField(
4051
+ controller: _spaceValueController,
4052
+ decoration: InputDecoration(
4053
+ labelText: 'Group / channel / room ID',
4054
+ ),
4055
+ onSubmitted: (_) => _submitManualRule(context),
4056
+ ),
4057
+ ],
4058
+ const SizedBox(height: 14),
4059
+ Align(
4060
+ alignment: Alignment.centerRight,
4061
+ child: FilledButton.icon(
4062
+ onPressed: () => _submitManualRule(context),
4063
+ icon: Icon(Icons.add_rounded),
4064
+ label: Text('Add rule'),
4065
+ ),
3604
4066
  ),
3605
4067
  ],
3606
4068
  ),