Merge branch 'status-privacy-overhaul' into 'main'

Status privacy overhaul

See merge request mysocialportal/relatica!48
This commit is contained in:
HankG 2023-11-18 04:20:49 +00:00
commit e3967b89bc
6 changed files with 287 additions and 80 deletions

View file

@ -5,7 +5,7 @@ import 'package:logging/logging.dart';
import '../../globals.dart'; import '../../globals.dart';
import '../../models/connection.dart'; import '../../models/connection.dart';
import '../../models/timeline_entry.dart'; import '../../models/timeline_entry.dart';
import '../../models/visibility.dart'; import '../../models/visibility.dart' as v;
import '../../routes.dart'; import '../../routes.dart';
import '../../services/auth_service.dart'; import '../../services/auth_service.dart';
import '../../services/connections_manager.dart'; import '../../services/connections_manager.dart';
@ -14,6 +14,7 @@ import '../../utils/active_profile_selector.dart';
import '../../utils/dateutils.dart'; import '../../utils/dateutils.dart';
import '../image_control.dart'; import '../image_control.dart';
import '../padding.dart'; import '../padding.dart';
import '../visibility_dialog.dart';
class StatusHeaderControl extends StatelessWidget { class StatusHeaderControl extends StatelessWidget {
static final _logger = Logger('$StatusHeaderControl'); static final _logger = Logger('$StatusHeaderControl');
@ -39,23 +40,23 @@ class StatusHeaderControl extends StatelessWidget {
.getForProfile(activeProfile) .getForProfile(activeProfile)
.transform((s) => s.getForPost(entry.id)?.resharers.firstOrNull) .transform((s) => s.getForPost(entry.id)?.resharers.firstOrNull)
.getValueOrElse(() => null); .getValueOrElse(() => null);
final manager = getIt<ActiveProfileSelector<ConnectionsManager>>()
getIt<ActiveProfileSelector<ConnectionsManager>>()
.getForProfile(activeProfile) .getForProfile(activeProfile)
.match( .fold(
onSuccess: (manager) { onSuccess: (m) => m,
author = onError: (error) {
manager.getById(entry.authorId).getValueOrElse(() => Connection()); _logger.severe('Error getting connection manager: $error');
reshareAuthor = reshareId == null return null;
? Connection() });
: manager.getById(reshareId).getValueOrElse(() => Connection());
}, author = manager!.getById(entry.authorId).getValueOrElse(
onError: (error) { () => Connection(),
_logger.severe('Error getting connections manageR: $error'); );
author = Connection(); reshareAuthor = reshareId == null
reshareAuthor = Connection(); ? Connection()
}, : manager.getById(reshareId).getValueOrElse(
); () => Connection(),
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -122,13 +123,17 @@ class StatusHeaderControl extends StatelessWidget {
ElapsedDateUtils.epochSecondsToString(entry.backdatedTimestamp), ElapsedDateUtils.epochSecondsToString(entry.backdatedTimestamp),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const HorizontalPadding(), IconButton(
Icon( onPressed: () async {
entry.visibility.type == VisibilityType.public await showVisibilityDialog(context, manager, entry.visibility);
? Icons.public },
: Icons.lock, icon: Icon(
color: Theme.of(context).hintColor, entry.visibility.type == v.VisibilityType.public
size: Theme.of(context).textTheme.bodySmall?.fontSize, ? Icons.public
: Icons.lock,
color: Theme.of(context).hintColor,
size: Theme.of(context).textTheme.bodySmall?.fontSize,
),
), ),
], ],
), ),

View file

@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../../models/visibility.dart' as v;
import '../services/connections_manager.dart';
Future<bool?> showVisibilityDialog(
BuildContext context,
ConnectionsManager cm,
v.Visibility visibility,
) async {
final circlesMap = {for (var item in cm.getMyCircles()) item.id: item};
final allowedCircles = visibility.allowedCircleIds.map((c) {
if (c == '~') {
return 'Followers';
}
return circlesMap[c]?.name ?? 'Circle #$c';
}).toList();
final excludedCircles = visibility.excludedCircleIds.map((c) {
if (c == '~') {
return 'Followers';
}
return circlesMap[c]?.name ?? 'Circle #$c';
}).toList();
final allowedUsers = visibility.allowedUserIds
.map(
(u) => cm.getById(u).fold(
onSuccess: (connection) => connection.handle,
onError: (_) => 'User $u',
),
)
.toList();
final excludedUsers = visibility.excludedUserIds
.map(
(u) => cm.getById(u).fold(
onSuccess: (connection) => connection.handle,
onError: (_) => 'User $u',
),
)
.toList();
return showDialog<bool>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return AlertDialog(
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Visibility Details',
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(decoration: TextDecoration.underline),
),
if (visibility.type == v.VisibilityType.public) ...[
const Text('Public')
],
if (visibility.type != v.VisibilityType.public) ...[
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Allowed Users: ',
style: TextStyle(fontWeight: FontWeight.bold),
),
Expanded(
child: Text(
allowedUsers.isEmpty
? 'Empty'
: allowedUsers.join(', '),
softWrap: true,
),
)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Text(
'Allowed Circles: ',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(
allowedCircles.isEmpty
? 'Empty'
: allowedCircles.join(','),
softWrap: true,
)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Text(
'Excluded Users: ',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(
excludedUsers.isEmpty ? 'Empty' : excludedUsers.join(','),
softWrap: true,
)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Text(
'Excluded Circles: ',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(
excludedCircles.isEmpty
? 'Empty'
: excludedCircles.join(','),
softWrap: true,
)
],
),
],
],
),
),
actions: <Widget>[
ElevatedButton(
child: const Text('Dismiss'),
onPressed: () {
Navigator.pop(context, true); // showDialog() returns true
},
),
],
);
},
);
}

View file

@ -843,7 +843,7 @@ class StatusesClient extends FriendicaClient {
if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText, if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText,
if (inReplyToId.isNotEmpty) 'in_reply_to_id': inReplyToId, if (inReplyToId.isNotEmpty) 'in_reply_to_id': inReplyToId,
if (mediaIds.isNotEmpty) 'media_ids': mediaIds, if (mediaIds.isNotEmpty) 'media_ids': mediaIds,
'visibility': visibility.toCreateStatusValue(), 'visibility': visibility.toCreateStatusValue(inReplyToId.isNotEmpty),
'friendica': { 'friendica': {
'title': '', 'title': '',
}, },

View file

@ -15,6 +15,7 @@ import '../controls/login_aware_cached_network_image.dart';
import '../controls/padding.dart'; import '../controls/padding.dart';
import '../controls/standard_appbar.dart'; import '../controls/standard_appbar.dart';
import '../controls/timeline/status_header_control.dart'; import '../controls/timeline/status_header_control.dart';
import '../controls/visibility_dialog.dart';
import '../globals.dart'; import '../globals.dart';
import '../models/circle_data.dart'; import '../models/circle_data.dart';
import '../models/exec_error.dart'; import '../models/exec_error.dart';
@ -24,6 +25,7 @@ import '../models/media_attachment_uploads/new_entry_media_items.dart';
import '../models/timeline_entry.dart'; import '../models/timeline_entry.dart';
import '../models/visibility.dart'; import '../models/visibility.dart';
import '../serializers/friendica/link_preview_friendica_extensions.dart'; import '../serializers/friendica/link_preview_friendica_extensions.dart';
import '../services/connections_manager.dart';
import '../services/feature_version_checker.dart'; import '../services/feature_version_checker.dart';
import '../services/timeline_manager.dart'; import '../services/timeline_manager.dart';
import '../utils/active_profile_selector.dart'; import '../utils/active_profile_selector.dart';
@ -566,13 +568,22 @@ class _EditorScreenState extends State<EditorScreen> {
Widget buildVisibilitySelector(BuildContext context) { Widget buildVisibilitySelector(BuildContext context) {
if (widget.forEditing || widget.parentId.isNotEmpty) { if (widget.forEditing || widget.parentId.isNotEmpty) {
final cm = context
.read<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry
.value;
return Row( return Row(
children: [ children: [
const Text('Visibility:'), const Text('Visibility:'),
const HorizontalPadding(), IconButton(
visibility.type == VisibilityType.public onPressed: () async {
? const Icon(Icons.public) await showVisibilityDialog(context, cm, visibility);
: const Icon(Icons.lock), },
icon: visibility.type == VisibilityType.public
? const Icon(Icons.public)
: const Icon(Icons.lock),
)
], ],
); );
} }
@ -583,67 +594,74 @@ class _EditorScreenState extends State<EditorScreen> {
.getValueOrElse(() => []); .getValueOrElse(() => []);
circles.sort((g1, g2) => g1.name.compareTo(g2.name)); circles.sort((g1, g2) => g1.name.compareTo(g2.name));
final circleMenuItems = <DropdownMenuEntry<CircleData>>[]; final circleMenuItems = <DropdownMenuItem<CircleData>>[];
circleMenuItems.add(DropdownMenuEntry( circleMenuItems.add(DropdownMenuItem(
value: CircleData.followersPseudoCircle, value: CircleData.followersPseudoCircle,
label: CircleData.followersPseudoCircle.name)); child: Text(CircleData.followersPseudoCircle.name)));
circleMenuItems.add(DropdownMenuEntry( circleMenuItems.add(DropdownMenuItem(
value: CircleData('', ''), label: '-', enabled: false)); value: CircleData('', ''), child: const Divider(), enabled: false));
circleMenuItems.addAll(circles.map((g) => DropdownMenuEntry( circleMenuItems.addAll(circles.map((g) => DropdownMenuItem(
value: g, value: g,
label: g.name, child: Text(g.name),
))); )));
if (!circles.contains(currentCircle)) { if (currentCircle != CircleData.followersPseudoCircle &&
!circles.contains(currentCircle)) {
currentCircle = null; currentCircle = null;
} }
return Row( return Row(
children: [ children: [
const Text('Visibility:'), const Text('Visibility:'),
const HorizontalPadding(), const HorizontalPadding(),
DropdownMenu<VisibilityType>( DropdownButton<VisibilityType>(
initialSelection: visibility.type, value: visibility.type,
enabled: !widget.forEditing, onChanged: widget.forEditing
onSelected: (value) { ? null
setState(() { : (value) {
if (value == VisibilityType.public) { setState(() {
visibility = Visibility.public(); if (value == VisibilityType.public) {
return; visibility = Visibility.public();
} return;
}
if (value == VisibilityType.private && currentCircle == null) { if (value == VisibilityType.private &&
visibility = Visibility.private(); currentCircle == null) {
return; visibility = Visibility.private();
} return;
}
visibility = Visibility( visibility = Visibility(
type: VisibilityType.private, type: VisibilityType.private,
allowedCircleIds: [currentCircle!.id], allowedCircleIds: [currentCircle!.id],
); );
}); });
}, },
dropdownMenuEntries: VisibilityType.values items: VisibilityType.values
.map((v) => DropdownMenuEntry( .map((v) => DropdownMenuItem(
value: v, value: v,
label: v.toLabel(), child: Text(v.toLabel()),
)) ))
.toList(), .toList(),
), ),
const HorizontalPadding(), const HorizontalPadding(),
if (visibility.type == VisibilityType.private) if (visibility.type == VisibilityType.private)
DropdownMenu<CircleData>( Expanded(
enabled: !widget.forEditing, child: DropdownButton<CircleData>(
initialSelection: currentCircle, value: currentCircle,
onSelected: (value) { isExpanded: true,
setState(() { onChanged: widget.forEditing
currentCircle = value; ? null
visibility = Visibility( : (value) {
type: VisibilityType.private, setState(() {
allowedCircleIds: currentCircle = value;
currentCircle == null ? [] : [currentCircle!.id], visibility = Visibility(
); type: VisibilityType.private,
}); allowedCircleIds:
}, currentCircle == null ? [] : [currentCircle!.id],
dropdownMenuEntries: circleMenuItems, );
});
},
items: circleMenuItems,
),
), ),
], ],
); );

View file

@ -71,9 +71,33 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
: 0; : 0;
final youReshared = json['reblogged'] ?? false; final youReshared = json['reblogged'] ?? false;
final visibility = ['public', 'unlisted'].contains(json['visibility']) late final Visibility visibility;
? Visibility.public() final visibilityString = json['visibility'];
: Visibility.private(); if (visibilityString == 'public') {
visibility = Visibility.public();
} else if (visibilityString == 'private') {
final allowedUserIds =
json['friendica']?['visibility']?['allow_cid'] as List<dynamic>? ??
[];
final excludedUserIds =
json['friendica']?['visibility']?['deny_cid'] as List<dynamic>? ?? [];
final allowedCircleIds =
json['friendica']?['visibility']?['allow_gid'] as List<dynamic>? ??
[];
final excludedCircleIds =
json['friendica']?['visibility']?['deny_gid'] as List<dynamic>? ?? [];
visibility = Visibility(
type: VisibilityType.private,
allowedUserIds: allowedUserIds.map((e) => e.toString()).toList(),
excludedUserIds: excludedUserIds.map((e) => e.toString()).toList(),
allowedCircleIds: allowedCircleIds.map((e) => e.toString()).toList(),
excludedCircleIds: excludedCircleIds.map((e) => e.toString()).toList(),
);
} else if (visibilityString == 'unlisted') {
visibility = Visibility.private();
} else {
visibility = Visibility.private();
}
const title = ''; const title = '';
final body = json['content'] ?? ''; final body = json['content'] ?? '';

View file

@ -3,20 +3,34 @@ import 'package:relatica/models/circle_data.dart';
import '../../models/visibility.dart'; import '../../models/visibility.dart';
extension VisibilityMastodonExtensions on Visibility { extension VisibilityMastodonExtensions on Visibility {
String toCreateStatusValue() { String toCreateStatusValue(bool onComment) {
if (type == VisibilityType.public) { if (type == VisibilityType.public) {
return 'public'; return 'public';
} }
if (hasDetails) { if (!onComment && hasDetails) {
final circleId = allowedCircleIds.first; final circleId =
allowedCircleIds.firstOrNull ?? allowedUserIds.firstOrNull;
if (circleId == CircleData.followersPseudoCircle.id) { if (circleId == CircleData.followersPseudoCircle.id) {
return 'private'; return 'private';
} }
return circleId; return circleId ?? 'private';
}
if (onComment && !hasDetails && type == VisibilityType.private) {
return 'direct';
} }
return 'private'; return 'private';
} }
Map<String, dynamic> friendicaExtensionVisibilityJson() {
return {
"allow_cid": allowedUserIds,
"deny_cid": excludedUserIds,
"allow_gid": allowedCircleIds,
"deny_gid": excludedCircleIds,
};
}
} }