Merge branch 'main' into codemagic-setup

This commit is contained in:
Hank Grabowski 2024-06-29 12:02:06 -04:00
commit 2db16401a4
48 changed files with 1454 additions and 344 deletions

View file

@ -1,5 +1,65 @@
# Relatica Change Log
## Version 0.11.0 (beta)
* Changes
* Connection request notifications won't have timestamps and can't be marked read. They go away
when they are
adjudicated. ([Issue #76](https://gitlab.com/mysocialportal/relatica/-/issues/76))
* Add more explicit text and catch when a user tries to use an email address rather than
username@servername if
using username/password
login. ([Issue #17](https://gitlab.com/mysocialportal/relatica/-/issues/17))
* Fixes
* Fixes Unlisted posts are showing as and sharing as
private ([Issue #78](https://gitlab.com/mysocialportal/relatica/-/issues/78))
* Fixes trailing CW type ([Issue #90](https://gitlab.com/mysocialportal/relatica/-/issues/90))
* Fix Threads open status in browser
errors ([Issue #87](https://gitlab.com/mysocialportal/relatica/-/issues/87))
* Fix Bluesky open status in browser
errors ([Issue #79](https://gitlab.com/mysocialportal/relatica/-/issues/79))
* Fix Threads profiles don't open in browser
properly ([Issue #89](https://gitlab.com/mysocialportal/relatica/-/issues/89))
* Fix Bluesky profiles don't open in browser
properly ([Issue #98](https://gitlab.com/mysocialportal/relatica/-/issues/98))
* Fixes being able to search Threads and Bluesky profiles and posts on the search
page ([Issue #92](https://gitlab.com/mysocialportal/relatica/-/issues/92))
* Multiple profiles from the same server now works again. Affected users have to use the new "
Clear All" button to
clear out existing credentials and re-add them all to fix
though. ([Feature #72](https://gitlab.com/mysocialportal/relatica/-/issues/72))
* Fix empty profiles and/or sometimes lack of bidirectional contact data by always pulling
profile data on refresh
requests and adding explicit redraw of panel after user requests
refresh ([Issue #36](https://gitlab.com/mysocialportal/relatica/-/issues/36), [Issue #62](https://gitlab.com/mysocialportal/relatica/-/issues/62),[Issue #70](https://gitlab.com/mysocialportal/relatica/-/issues/70))
* Fix bug where read notifications would never load if there were pending connection requests or
unread
DMs ([Issue #101](https://gitlab.com/mysocialportal/relatica/-/issues/101))
* New Features
* Shows the network of the
post/comment ([Feature #82](https://gitlab.com/mysocialportal/relatica/-/issues/82))
* User configurable ability to limit reacting to, commenting on, or resharing posts by network
type([Feature #93](https://gitlab.com/mysocialportal/relatica/-/issues/93))
* Notifications are grouped by type, starting with mentions, within the unread and read
groupings of the
notification list. Defaults to on by default but can be toggled off in
settings.([Feature #65](https://gitlab.com/mysocialportal/relatica/-/issues/65))
* Ability to turn off Spoiler Alert/CWs at the application
level. Defaults to
on. ([Feature #42](https://gitlab.com/mysocialportal/relatica/-/issues/42))
* Throws a confirm dialog box up if adding a comment to a post/comment over 30 days
old. ([Feature #58](https://gitlab.com/mysocialportal/relatica/-/issues/58))
* Autocomplete now lists hashtags and accounts that are used in a post or post above the rest of
the
results. ([Feature #28](https://gitlab.com/mysocialportal/relatica/-/issues/28))
* Show delivery data for logged in user's posts and comments (not
reshares) ([Feature #66](https://gitlab.com/mysocialportal/relatica/-/issues/66))
* Add spellcheck highlighting in text fields (iOS and Android
only) ([Feature #39](https://gitlab.com/mysocialportal/relatica/-/issues/39))
* Add tie into suggestions from system password manager (confirmed works on iOS only so
far) ([Feature #14](https://gitlab.com/mysocialportal/relatica/-/issues/14))
## Version 0.10.1 (beta)
* Changes

3
devtools_options.yaml Normal file
View file

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

Binary file not shown.

View file

@ -1,21 +1,34 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../globals.dart';
import '../../services/entry_manager_service.dart';
import '../../services/hashtag_service.dart';
import '../../utils/active_profile_selector.dart';
class HashtagAutocompleteOptions extends StatelessWidget {
const HashtagAutocompleteOptions({
super.key,
required this.id,
required this.query,
required this.onHashtagTap,
});
final String id;
final String query;
final ValueSetter<String> onHashtagTap;
@override
Widget build(BuildContext context) {
final hashtags = getIt<HashtagService>().getMatchingHashTags(query);
final manager = context
.read<ActiveProfileSelector<EntryManagerService>>()
.activeEntry
.value;
final postTreeHashtags =
manager.getPostTreeHashtags(id).getValueOrElse(() => [])..sort();
final hashtagsFromService =
getIt<HashtagService>().getMatchingHashTags(query);
final hashtags = [...postTreeHashtags, ...hashtagsFromService];
if (hashtags.isEmpty) return const SizedBox.shrink();

View file

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../../globals.dart';
import '../../models/connection.dart';
import '../../services/connections_manager.dart';
import '../../services/entry_manager_service.dart';
import '../../utils/active_profile_selector.dart';
import '../image_control.dart';
@ -12,24 +13,39 @@ class MentionAutocompleteOptions extends StatelessWidget {
const MentionAutocompleteOptions({
super.key,
required this.id,
required this.query,
required this.onMentionUserTap,
});
final String id;
final String query;
final ValueSetter<Connection> onMentionUserTap;
@override
Widget build(BuildContext context) {
final users = getIt<ActiveProfileSelector<ConnectionsManager>>()
final entryManager = context
.read<ActiveProfileSelector<EntryManagerService>>()
.activeEntry
.andThenSuccess((manager) => manager.getKnownUsersByName(query))
.fold(
onSuccess: (users) => users,
onError: (error) {
_logger.severe('Error getting users list: $error');
return [];
});
.value;
final connectionManager = context
.read<ActiveProfileSelector<ConnectionsManager>>()
.activeEntry
.value;
final postTreeUsers = entryManager
.getPostTreeConnectionIds(id)
.getValueOrElse(() => [])
.map((id) => connectionManager.getById(id))
.where((result) => result.isSuccess)
.map((result) => result.value)
.toList()
..sort((u1, u2) => u1.name.compareTo(u2.name));
final knownUsers = connectionManager.getKnownUsersByName(query);
final users = [...postTreeUsers, ...knownUsers];
if (users.isEmpty) return const SizedBox.shrink();

View file

@ -66,6 +66,8 @@ class _MediaUploadEditorControlState extends State<MediaUploadEditorControl> {
TextFormField(
initialValue: widget.media.description,
onChanged: (value) => widget.media.description = value,
textCapitalization: TextCapitalization.sentences,
spellCheckConfiguration: const SpellCheckConfiguration(),
maxLines: 5,
decoration: InputDecoration(
labelText: 'Description/ALT Text',

View file

@ -147,11 +147,13 @@ class NotificationControl extends StatelessWidget {
: GestureDetector(
onTap: onTap,
child: Text(
ElapsedDateUtils.epochSecondsToString(notification.timestamp),
ElapsedDateUtils.elapsedTimeStringFromEpochSeconds(
notification.timestamp),
),
),
trailing: notification.dismissed ||
notification.type == NotificationType.direct_message
notification.type == NotificationType.direct_message ||
notification.type == NotificationType.follow_request
? null
: IconButton(
onPressed: manager == null

View file

@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../globals.dart';
import '../models/timeline_entry.dart';
import '../services/auth_service.dart';
import '../services/setting_service.dart';
import '../utils/clipboard_utils.dart';
import '../utils/url_opening_utils.dart';
import 'html_text_viewer_control.dart';
@ -27,6 +29,7 @@ class SearchResultStatusControl extends StatefulWidget {
}
class _SearchResultStatusControlState extends State<SearchResultStatusControl> {
var showSpoilerControl = true;
var showContent = false;
TimelineEntry get status => widget.status;
@ -34,7 +37,9 @@ class _SearchResultStatusControlState extends State<SearchResultStatusControl> {
@override
void initState() {
super.initState();
showContent = widget.status.spoilerText.isEmpty;
showSpoilerControl = getIt<SettingsService>().spoilerHidingEnabled;
showContent =
!showSpoilerControl ? true : widget.status.spoilerText.isEmpty;
}
@override
@ -77,7 +82,7 @@ class _SearchResultStatusControlState extends State<SearchResultStatusControl> {
const VerticalPadding(
height: 5,
),
if (status.spoilerText.isNotEmpty)
if (showSpoilerControl && status.spoilerText.isNotEmpty)
TextButton(
onPressed: () {
setState(() {
@ -85,7 +90,7 @@ class _SearchResultStatusControlState extends State<SearchResultStatusControl> {
});
},
child: Text(
'Content Summary: ${status.spoilerText} (Click to ${showContent ? "Hide" : "Show"}}')),
'Content Summary: ${status.spoilerText} (Click to ${showContent ? "Hide" : "Show"})')),
if (showContent) ...[
buildBody(context),
const VerticalPadding(

View file

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:relatica/utils/snackbar_builder.dart';
import 'package:result_monad/result_monad.dart';
import '../../globals.dart';
@ -13,6 +12,7 @@ import '../../models/filters/timeline_entry_filter.dart';
import '../../models/flattened_tree_item.dart';
import '../../models/timeline_entry.dart';
import '../../services/auth_service.dart';
import '../../services/setting_service.dart';
import '../../services/timeline_entry_filter_service.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
@ -20,6 +20,7 @@ import '../../utils/clipboard_utils.dart';
import '../../utils/filter_runner.dart';
import '../../utils/html_to_edit_text_helper.dart';
import '../../utils/responsive_sizes_calculator.dart';
import '../../utils/snackbar_builder.dart';
import '../../utils/url_opening_utils.dart';
import '../html_text_viewer_control.dart';
import '../media_attachment_viewer_control.dart';
@ -49,6 +50,7 @@ class FlattenedTreeEntryControl extends StatefulWidget {
class _StatusControlState extends State<FlattenedTreeEntryControl> {
static final _logger = Logger('$FlattenedTreeEntryControl');
var showSpoilerControl = true;
var showContent = true;
var showFilteredPost = false;
var showComments = false;
@ -67,7 +69,8 @@ class _StatusControlState extends State<FlattenedTreeEntryControl> {
@override
void initState() {
super.initState();
showContent = entry.spoilerText.isEmpty;
showSpoilerControl = getIt<SettingsService>().spoilerHidingEnabled;
showContent = !showSpoilerControl ? true : entry.spoilerText.isEmpty;
showComments = isPost ? false : true;
}
@ -164,7 +167,7 @@ class _StatusControlState extends State<FlattenedTreeEntryControl> {
const VerticalPadding(
height: 5,
),
if (entry.spoilerText.isNotEmpty)
if (showSpoilerControl && entry.spoilerText.isNotEmpty)
TextButton(
onPressed: () {
setState(() {
@ -172,7 +175,7 @@ class _StatusControlState extends State<FlattenedTreeEntryControl> {
});
},
child: Text(
'Content Summary: ${entry.spoilerText} (Click to ${showContent ? "Hide" : "Show"}}')),
'Content Summary: ${entry.spoilerText} (Click to ${showContent ? "Hide" : "Show"})')),
if (showContent) ...[
buildContentField(context),
const VerticalPadding(

View file

@ -1,16 +1,17 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:relatica/models/exec_error.dart';
import 'package:result_monad/result_monad.dart';
import '../../globals.dart';
import '../../models/exec_error.dart';
import '../../models/timeline_entry.dart';
import '../../models/visibility.dart' as v;
import '../../services/feature_version_checker.dart';
import '../../services/fediverse_server_validator.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
import '../../utils/dateutils.dart';
import '../../utils/interaction_availability_util.dart';
import '../../utils/snackbar_builder.dart';
class InteractionsBarControl extends StatefulWidget {
@ -74,6 +75,7 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
isProcessing = true;
});
// TODO Add can reshare check
final fvc = getIt<FriendicaVersionChecker>();
if (!fvc.canUseFeature(RelaticaFeatures.diasporaReshare)) {
final serverTypeEstimate = await getIt<FediverseServiceValidator>()
@ -112,6 +114,23 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
}
Future<void> addComment() async {
if (mounted) {
final elapsed = ElapsedDateUtils.elapsedTimeFromEpochSeconds(
widget.entry.creationTimestamp);
if (elapsed > const Duration(days: 30)) {
final label = ElapsedDateUtils.elapsedTimeStringFromEpochSeconds(
widget.entry.creationTimestamp);
final confirm = await showYesNoDialog(
context,
'Entry is from $label. Are you sure you want to add a comment on it?',
);
if (confirm != true) {
return;
}
}
}
if (mounted) {
context.push('/comment/new?parent_id=${widget.entry.id}');
}
@ -160,39 +179,39 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
}
Widget buildLikeButton() {
final canReact = widget.entry.getCanReact();
final tooltip =
canReact.canDo ? 'Press to toggle like/unlike' : canReact.reason;
return buildButton(
isFavorited ? Icons.thumb_up : Icons.thumb_up_outlined,
likes,
true,
'Press to toggle like/unlike',
() async => await toggleFavorited(),
tooltip,
canReact.canDo ? () async => await toggleFavorited() : null,
);
}
Widget buildCommentButton() {
final canComment = widget.entry.getCanComment();
final tooltip =
canComment.canDo ? 'Press to add a comment' : canComment.reason;
return buildButton(
Icons.comment,
comments,
true,
'Press to add a comment',
() async => await addComment(),
tooltip,
canComment.canDo ? () async => await addComment() : null,
);
}
Widget buildReshareButton() {
final canReshare = !widget.isMine &&
widget.entry.visibility.type == v.VisibilityType.public;
final reshareable = widget.entry.getIsReshareable(widget.isMine);
final canReshare = reshareable.canDo;
late final String tooltip;
if (canReshare) {
tooltip = youReshared ? 'Press to undo reshare' : 'Press to reshare';
} else {
if (widget.isMine) {
tooltip = "Can't reshare your own post";
} else if (widget.entry.visibility.type != v.VisibilityType.public) {
tooltip = "Can't reshare a private post";
} else {
tooltip = "Can't reshare at this time";
}
tooltip = reshareable.reason;
}
return buildButton(
youReshared ? Icons.repeat_on_outlined : Icons.repeat,

View file

@ -38,8 +38,6 @@ class _PostControlState extends State<PostControl> {
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
var showContent = true;
EntryTreeItem get item => widget.originalItem;
TimelineEntry get entry => item.entry;
@ -47,7 +45,6 @@ class _PostControlState extends State<PostControl> {
@override
void initState() {
super.initState();
showContent = entry.spoilerText.isEmpty;
}
@override

View file

@ -15,6 +15,7 @@ import '../../utils/dateutils.dart';
import '../image_control.dart';
import '../padding.dart';
import '../visibility_dialog.dart';
import 'timeline_network_info_control.dart';
class StatusHeaderControl extends StatelessWidget {
static final _logger = Logger('$StatusHeaderControl');
@ -120,24 +121,65 @@ class StatusHeaderControl extends StatelessWidget {
Row(
children: [
Text(
ElapsedDateUtils.epochSecondsToString(entry.backdatedTimestamp),
ElapsedDateUtils.elapsedTimeStringFromEpochSeconds(
entry.backdatedTimestamp),
style: Theme.of(context).textTheme.bodySmall,
),
IconButton(
tooltip:
'Visibility: ${entry.visibility.type.toLabel()} (click for details)',
onPressed: () async {
await showVisibilityDialog(context, manager, entry.visibility);
},
icon: Icon(
entry.visibility.type == v.VisibilityType.public
? Icons.public
: Icons.lock,
switch (entry.visibility.type) {
v.VisibilityType.public => Icons.public,
v.VisibilityType.private => Icons.lock,
v.VisibilityType.unlisted => Icons.not_interested,
},
color: Theme.of(context).hintColor,
size: Theme.of(context).textTheme.bodySmall?.fontSize,
),
),
TimelineNetworkInfoControl(info: entry.networkInfo),
if (entry.deliveryData.hasDeliveryData) ...[
const HorizontalPadding(),
buildDeliveryIndicator(context),
],
],
),
],
);
}
Widget buildDeliveryIndicator(BuildContext context) {
final data = entry.deliveryData;
const fullyDeliveredIcon = '\uf1d8'; //fa-paper-plane
const inDeliveryIcon = '\uf1d9'; //fa-paper-plane-o
final percentRemaining =
data.leftForDelivery.toDouble() / data.total.toDouble();
final iconText =
percentRemaining > 0.1 ? inDeliveryIcon : fullyDeliveredIcon;
return GestureDetector(
onTap: () async {
final text = """
Federated Messages Deliveries to Remote Servers
Total: ${data.total}
Completed: ${data.done}
Failed: ${data.failed}
Remaining: ${data.leftForDelivery}
""";
showConfirmDialog(context, text);
},
child: Tooltip(
message:
'Deliveries to remote servers: ${entry.deliveryData.done}/${entry.deliveryData.total}',
child: Text(
iconText,
style: const TextStyle(fontFamily: 'ForkAwesome'),
),
),
);
}
}

View file

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import '../../globals.dart';
import '../../models/timeline_network_info.dart';
import '../../utils/known_network_extensions.dart';
class TimelineNetworkInfoControl extends StatelessWidget {
final TimelineNetworkInfo info;
const TimelineNetworkInfoControl({super.key, required this.info});
@override
Widget build(BuildContext context) {
final networkText =
info.network == KnownNetworks.unknown ? info.name : info.labelName;
return GestureDetector(
onTap: () async => showConfirmDialog(context, networkText),
child: Tooltip(
message: networkText,
child: Text(
info.forkAwesomeUnicode,
style: const TextStyle(fontFamily: 'ForkAwesome'),
),
),
);
}
}

View file

@ -7,6 +7,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../models/TimelineIdentifiers.dart';
import '../../services/network_status_service.dart';
import '../../services/setting_service.dart';
import '../../services/timeline_manager.dart';
import '../../utils/active_profile_selector.dart';
import 'post_control.dart';
@ -36,6 +37,7 @@ class TimelinePanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
_logger.finer('Build');
context.watch<SettingsService>().spoilerHidingEnabled;
final nss = getIt<NetworkStatusService>();
final manager = context
.watch<ActiveProfileSelector<TimelineManager>>()

View file

@ -66,6 +66,9 @@ Future<bool?> showVisibilityDialog(
if (visibility.type == v.VisibilityType.public) ...[
const Text('Public')
],
if (visibility.type == v.VisibilityType.unlisted) ...[
const Text('Unlisted')
],
if (visibility.type != v.VisibilityType.public) ...[
Row(
mainAxisAlignment: MainAxisAlignment.start,

View file

@ -554,6 +554,14 @@ class RelationshipsClient extends FriendicaClient {
_networkStatusService.startConnectionUpdateStatus();
final myId = profile.userId;
final id = int.parse(connection.id);
final connectionUpdateUrl =
Uri.parse('https://$serverName/api/v1/accounts/$id');
final updatedConnection = await _getApiRequest(connectionUpdateUrl).fold(
onSuccess: (json) => ConnectionMastodonExtensions.fromJson(json),
onError: (error) {
_logger.severe('Error getting connection for $id');
return connection;
});
final paging = '?min_id=${id - 1}&max_id=${id + 1}';
final baseUrl = 'https://$serverName/api/v1/accounts/$myId';
final following =
@ -581,7 +589,7 @@ class RelationshipsClient extends FriendicaClient {
}
_networkStatusService.finishConnectionUpdateStatus();
return Result.ok(connection.copy(status: status));
return Result.ok(updatedConnection.copy(status: status));
}
FutureResult<PagedResponse<List<Connection>>, ExecError>
@ -779,18 +787,21 @@ class ProfileClient extends FriendicaClient {
ProfileClient(super.credentials) : super();
FutureResult<Connection, ExecError> getMyProfile() async {
FutureResult<(Connection, Profile), ExecError> getMyProfile() async {
_logger.finest(() => 'Getting logged in user profile');
final request =
Uri.parse('https://$serverName/api/v1/accounts/verify_credentials');
return (await _getApiRequest(request, timeout: oauthTimeout))
.mapValue((json) => ConnectionMastodonExtensions.fromJson(
json,
defaultServerName: serverName,
).copy(
status: ConnectionStatus.you,
network: 'friendica',
));
.mapValue((json) {
final connection = ConnectionMastodonExtensions.fromJson(
json,
defaultServerName: serverName,
).copy(
status: ConnectionStatus.you,
network: 'friendica',
);
return (connection, profile);
});
}
}

View file

@ -52,6 +52,7 @@ class BasicCredentials implements ICredentials {
String? serverName,
}) {
return BasicCredentials(
id: id,
username: username ?? this.username,
password: password ?? this.password,
serverName: serverName ?? this.serverName,

View file

@ -76,6 +76,16 @@ class OAuthCredentials implements ICredentials {
'id': id,
};
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is OAuthCredentials &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
static OAuthCredentials fromJson(Map<String, dynamic> json) =>
OAuthCredentials(
clientId: json['clientId'],

View file

@ -0,0 +1,17 @@
class DeliveryData {
static const empty = DeliveryData(total: 0, done: 0, failed: 0);
final int total;
final int done;
final int failed;
const DeliveryData({
required this.total,
required this.done,
required this.failed,
});
bool get hasDeliveryData => total > 0;
int get leftForDelivery => total - done - failed;
}

View file

@ -0,0 +1,121 @@
import '../timeline_network_info.dart';
class NetworkCapabilitiesSettings {
late final List<NetworkCapabilitiesItem> _capabilities;
late final Map<KnownNetworks, NetworkCapabilitiesItem> _capabilitiesKV;
num get length => _capabilities.length;
NetworkCapabilitiesSettings(
{required List<NetworkCapabilitiesItem> capabilities}) {
_capabilities = capabilities;
_capabilitiesKV = {for (final c in capabilities) c.network: c};
}
NetworkCapabilitiesItem operator [](int i) => _capabilities[i];
NetworkCapabilitiesItem getCapabilities(KnownNetworks network) =>
_capabilitiesKV[network] ?? NetworkCapabilitiesItem.unknown();
operator []=(int i, NetworkCapabilitiesItem item) {
_capabilities[i] = item;
_capabilitiesKV[item.network] = item;
}
factory NetworkCapabilitiesSettings.defaultSettings() {
final networks = <KnownNetworks>{};
networks.add(KnownNetworks.friendica);
networks.add(KnownNetworks.mastodon);
networks.add(KnownNetworks.threads);
networks.add(KnownNetworks.bluesky);
networks.add(KnownNetworks.diaspora);
networks.add(KnownNetworks.pixelfed);
networks.add(KnownNetworks.peertube);
networks.add(KnownNetworks.unknown);
networks.addAll(KnownNetworks.values);
final capabilities = networks
.map((e) => switch (e) {
KnownNetworks.activityPub => NetworkCapabilitiesItem(
network: e,
react: true,
reshare: true,
comment: true,
),
KnownNetworks.bluesky => NetworkCapabilitiesItem(
network: e,
react: true,
reshare: false,
comment: true,
),
KnownNetworks.threads => NetworkCapabilitiesItem(
network: e,
react: true,
reshare: false,
comment: false,
),
_ => NetworkCapabilitiesItem(
network: e,
react: true,
reshare: true,
comment: true,
),
})
.toList();
return NetworkCapabilitiesSettings(capabilities: capabilities);
}
factory NetworkCapabilitiesSettings.fromJson(List<dynamic> json) {
final capabilities =
json.map((j) => NetworkCapabilitiesItem.fromJson(j)).toList();
return NetworkCapabilitiesSettings(capabilities: capabilities);
}
List<Map<String, dynamic>> toJson() {
return _capabilities.map((c) => c.toJson()).toList();
}
}
class NetworkCapabilitiesItem {
final KnownNetworks network;
final bool react;
final bool reshare;
final bool comment;
NetworkCapabilitiesItem({
required this.network,
required this.react,
required this.reshare,
required this.comment,
});
factory NetworkCapabilitiesItem.fromJson(Map<String, dynamic> json) =>
NetworkCapabilitiesItem(
network: KnownNetworks.parse(json['network']),
react: json['react'] ?? true,
reshare: json['reshare'] ?? true,
comment: json['comment'] ?? true,
);
factory NetworkCapabilitiesItem.unknown() => NetworkCapabilitiesItem(
network: KnownNetworks.unknown,
react: true,
reshare: true,
comment: true,
);
NetworkCapabilitiesItem copyWith(
{bool? react, bool? reshare, bool? comment}) =>
NetworkCapabilitiesItem(
network: network,
react: react ?? this.react,
reshare: reshare ?? this.reshare,
comment: comment ?? this.comment,
);
Map<String, dynamic> toJson() => {
'network': network.name,
'react': react,
'reshare': reshare,
'comment': comment,
};
}

View file

@ -1,10 +1,12 @@
import '../globals.dart';
import 'connection.dart';
import 'delivery_data.dart';
import 'engagement_summary.dart';
import 'link_data.dart';
import 'link_preview_data.dart';
import 'location_data.dart';
import 'media_attachment.dart';
import 'timeline_network_info.dart';
import 'visibility.dart';
class TimelineEntry {
@ -52,10 +54,14 @@ class TimelineEntry {
final List<MediaAttachment> mediaAttachments;
final EngagementSummary engagementSummary;
final TimelineNetworkInfo networkInfo;
final LinkPreviewData? linkPreviewData;
final EngagementSummary engagementSummary;
final DeliveryData deliveryData;
TimelineEntry(
{this.id = '',
this.parentId = '',
@ -80,7 +86,9 @@ class TimelineEntry {
this.dislikes = const [],
this.mediaAttachments = const [],
this.engagementSummary = const EngagementSummary(),
this.linkPreviewData})
this.networkInfo = TimelineNetworkInfo.empty,
this.linkPreviewData,
this.deliveryData = DeliveryData.empty})
: visibility = visibility ?? Visibility.public();
TimelineEntry.randomBuilt()
@ -108,8 +116,10 @@ class TimelineEntry {
likes = [],
dislikes = [],
mediaAttachments = [],
networkInfo = TimelineNetworkInfo.empty,
engagementSummary = const EngagementSummary(),
linkPreviewData = LinkPreviewData(link: 'fake link');
linkPreviewData = LinkPreviewData(link: 'fake link'),
deliveryData = DeliveryData.empty;
TimelineEntry copy({
int? creationTimestamp,
@ -138,7 +148,9 @@ class TimelineEntry {
List<Connection>? dislikes,
List<MediaAttachment>? mediaAttachments,
EngagementSummary? engagementSummary,
TimelineNetworkInfo? networkInfo,
LinkPreviewData? linkPreviewData,
DeliveryData? deliveryData,
}) {
return TimelineEntry(
creationTimestamp: creationTimestamp ?? this.creationTimestamp,
@ -165,7 +177,9 @@ class TimelineEntry {
dislikes: dislikes ?? this.dislikes,
mediaAttachments: mediaAttachments ?? this.mediaAttachments,
engagementSummary: engagementSummary ?? this.engagementSummary,
networkInfo: networkInfo ?? this.networkInfo,
linkPreviewData: linkPreviewData ?? this.linkPreviewData,
deliveryData: deliveryData ?? this.deliveryData,
);
}
@ -205,7 +219,9 @@ class TimelineEntry {
likes == other.likes &&
dislikes == other.dislikes &&
mediaAttachments == other.mediaAttachments &&
engagementSummary == other.engagementSummary;
networkInfo == other.networkInfo &&
engagementSummary == other.engagementSummary &&
deliveryData == other.deliveryData;
@override
int get hashCode =>
@ -231,5 +247,7 @@ class TimelineEntry {
likes.hashCode ^
dislikes.hashCode ^
mediaAttachments.hashCode ^
engagementSummary.hashCode;
networkInfo.hashCode ^
engagementSummary.hashCode ^
deliveryData.hashCode;
}

View file

@ -0,0 +1,68 @@
enum KnownNetworks {
activityPub,
bluesky,
calckey,
diaspora,
drupal,
firefish,
friendica,
funkwhale,
gnu_social,
hubzilla,
kbin,
lemmy,
mastodon,
nextcloud,
peertube,
pixelfed,
pleroma,
plume,
red,
redmatrix,
socialhome,
threads,
wordpress,
unknown,
;
static KnownNetworks parse(String? text) {
if (text == null) {
return unknown;
}
return values.firstWhere(
(e) => e.name == text,
orElse: () => unknown,
);
}
}
class TimelineNetworkInfo {
static const empty = TimelineNetworkInfo(
name: 'Unknown',
vapidKey: '',
network: KnownNetworks.unknown,
);
final String name;
final String vapidKey;
final KnownNetworks network;
const TimelineNetworkInfo({
required this.name,
required this.vapidKey,
required this.network,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TimelineNetworkInfo &&
runtimeType == other.runtimeType &&
name == other.name &&
vapidKey == other.vapidKey &&
network == other.network;
@override
int get hashCode => name.hashCode ^ vapidKey.hashCode ^ network.hashCode;
}

View file

@ -1,6 +1,7 @@
enum VisibilityType {
public,
private,
unlisted,
;
String toLabel() {
@ -9,6 +10,8 @@ enum VisibilityType {
return 'Public';
case VisibilityType.private:
return 'Private';
case VisibilityType.unlisted:
return 'Unlisted';
}
}
}
@ -46,6 +49,10 @@ class Visibility {
type: VisibilityType.private,
);
factory Visibility.unlisted() => const Visibility(
type: VisibilityType.unlisted,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||

View file

@ -281,6 +281,10 @@ class _EditorScreenState extends State<EditorScreen> {
buildVisibilitySelector(context),
const VerticalPadding(),
buildContentField(context),
CharacterCountWidget(
contentController: contentController,
linkPreviewController: linkPreviewController,
),
const VerticalPadding(),
buildLinkWithPreview(context),
const VerticalPadding(),
@ -329,6 +333,7 @@ class _EditorScreenState extends State<EditorScreen> {
trigger: '@',
optionsViewBuilder: (context, autocompleteQuery, controller) {
return MentionAutocompleteOptions(
id: parentEntry?.id ?? '',
query: autocompleteQuery.query,
onMentionUserTap: (user) {
final autocomplete = MultiTriggerAutocomplete.of(context);
@ -341,6 +346,7 @@ class _EditorScreenState extends State<EditorScreen> {
trigger: '#',
optionsViewBuilder: (context, autocompleteQuery, controller) {
return HashtagAutocompleteOptions(
id: parentEntry?.id ?? '',
query: autocompleteQuery.query,
onHashtagTap: (hashtag) {
final autocomplete = MultiTriggerAutocomplete.of(context);
@ -357,6 +363,7 @@ class _EditorScreenState extends State<EditorScreen> {
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
controller: controller,
spellCheckConfiguration: const SpellCheckConfiguration(),
decoration: InputDecoration(
labelText: '$statusType Content',
alignLabelWithHint: true,
@ -580,13 +587,14 @@ class _EditorScreenState extends State<EditorScreen> {
children: [
const Text('Visibility:'),
IconButton(
onPressed: () async {
await showVisibilityDialog(context, cm, visibility);
},
icon: visibility.type == VisibilityType.public
? const Icon(Icons.public)
: const Icon(Icons.lock),
)
onPressed: () async {
await showVisibilityDialog(context, cm, visibility);
},
icon: Icon(switch (visibility.type) {
VisibilityType.public => Icons.public,
VisibilityType.private => Icons.lock,
VisibilityType.unlisted => Icons.not_interested,
}))
],
);
}
@ -632,6 +640,12 @@ class _EditorScreenState extends State<EditorScreen> {
return;
}
if (value == VisibilityType.unlisted &&
currentCircle == null) {
visibility = Visibility.unlisted();
return;
}
visibility = Visibility(
type: VisibilityType.private,
allowedCircleIds: [currentCircle!.id],
@ -687,3 +701,52 @@ class _EditorScreenState extends State<EditorScreen> {
});
}
}
class CharacterCountWidget extends StatefulWidget {
final TextEditingController contentController;
final TextEditingController linkPreviewController;
const CharacterCountWidget({
super.key,
required this.contentController,
required this.linkPreviewController,
});
@override
State<CharacterCountWidget> createState() => _CharacterCountWidgetState();
}
class _CharacterCountWidgetState extends State<CharacterCountWidget> {
var count = 0;
@override
void initState() {
super.initState();
calculateCount();
widget.contentController.addListener(countRefresh);
widget.linkPreviewController.addListener(countRefresh);
}
@override
void dispose() {
widget.contentController.removeListener(countRefresh);
widget.linkPreviewController.removeListener(countRefresh);
super.dispose();
}
void countRefresh() {
setState(() {
calculateCount();
});
}
void calculateCount() {
count = widget.linkPreviewController.text.length +
widget.contentController.text.length;
}
@override
Widget build(BuildContext context) {
return Text('Character Count: $count');
}
}

View file

@ -407,6 +407,7 @@ class _FilterEditorScreenState extends State<FilterEditorScreen> {
optionsViewBuilder:
(ovbContext, autocompleteQuery, controller) {
return MentionAutocompleteOptions(
id: '',
query: autocompleteQuery.query,
onMentionUserTap: (user) {
final autocomplete =
@ -480,6 +481,7 @@ class _FilterEditorScreenState extends State<FilterEditorScreen> {
optionsViewBuilder:
(ovbContext, autocompleteQuery, controller) {
return HashtagAutocompleteOptions(
id: '',
query: autocompleteQuery.query,
onHashtagTap: (hashtag) {
final autocomplete =

View file

@ -106,6 +106,8 @@ class _MessageThreadScreenState extends State<MessageThreadScreen> {
child: ResponsiveMaxWidth(
child: TextFormField(
controller: textController,
textCapitalization: TextCapitalization.sentences,
spellCheckConfiguration: const SpellCheckConfiguration(),
maxLines: 4,
decoration: InputDecoration(
labelText: 'Reply Text',

View file

@ -85,7 +85,7 @@ class MessagesScreen extends StatelessWidget {
style: style,
),
trailing: Text(
ElapsedDateUtils.epochSecondsToString(
ElapsedDateUtils.elapsedTimeStringFromEpochSeconds(
thread.messages.last.createdAt),
style: style,
),

View file

@ -49,6 +49,7 @@ class MessagesNewThread extends StatelessWidget {
trigger: '@',
optionsViewBuilder: (context, autocompleteQuery, controller) {
return MentionAutocompleteOptions(
id: '',
query: autocompleteQuery.query,
onMentionUserTap: (user) {
final autocomplete =
@ -76,6 +77,8 @@ class MessagesNewThread extends StatelessWidget {
const VerticalPadding(),
TextFormField(
controller: replyController,
textCapitalization: TextCapitalization.sentences,
spellCheckConfiguration: const SpellCheckConfiguration(),
maxLines: 8,
decoration: InputDecoration(
labelText: 'Reply Text',

View file

@ -12,6 +12,7 @@ import '../di_initialization.dart';
import '../globals.dart';
import '../routes.dart';
import '../services/setting_service.dart';
import '../utils/known_network_extensions.dart';
import '../utils/theme_mode_extensions.dart';
class SettingsScreen extends StatelessWidget {
@ -30,10 +31,13 @@ class SettingsScreen extends StatelessWidget {
children: [
buildVersionString(),
buildLowBandwidthWidget(settings),
buildNotificationGroupingWidget(settings),
buildSpoilerHidingEnabledWidget(settings),
buildThemeWidget(settings),
if (!kReleaseMode) buildColorBlindnessTestSettings(settings),
buildClearCaches(context),
buildLogPanel(context, settings),
const NetworkCapabilitiesWidget(),
],
),
),
@ -65,6 +69,30 @@ class SettingsScreen extends StatelessWidget {
);
}
Widget buildNotificationGroupingWidget(SettingsService settings) {
return ListTile(
title: const Text('Group notifications by type'),
trailing: Switch(
onChanged: (value) {
settings.notificationGrouping = value;
},
value: settings.notificationGrouping,
),
);
}
Widget buildSpoilerHidingEnabledWidget(SettingsService settings) {
return ListTile(
title: const Text('Spoiler/Content Warning Hiding'),
trailing: Switch(
onChanged: (value) {
settings.spoilerHidingEnabled = value;
},
value: settings.spoilerHidingEnabled,
),
);
}
Widget buildThemeWidget(SettingsService settings) {
return ListTile(
title: const Text('Dark Mode Theme:'),
@ -138,3 +166,77 @@ class SettingsScreen extends StatelessWidget {
);
}
}
class NetworkCapabilitiesWidget extends StatelessWidget {
const NetworkCapabilitiesWidget({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final nc = settings.networkCapabilities;
final rows = <DataRow>[];
for (int i = 0; i < nc.length; i++) {
final e = nc[i];
final r = DataRow(
cells: [
DataCell(
Text(
'${e.network.forkAwesomeUnicode} ${e.network.labelName}',
style: const TextStyle(fontFamily: 'ForkAwesome'),
),
),
DataCell(
Checkbox(
value: e.react,
onChanged: (bool? value) {
nc[i] = e.copyWith(react: value ?? false);
settings.networkCapabilities = nc;
},
),
),
DataCell(
Checkbox(
value: e.reshare,
onChanged: (bool? value) {
nc[i] = e.copyWith(reshare: value ?? false);
settings.networkCapabilities = nc;
},
),
),
DataCell(
Checkbox(
value: e.comment,
onChanged: (bool? value) {
nc[i] = e.copyWith(comment: value ?? false);
settings.networkCapabilities = nc;
},
),
),
],
);
rows.add(r);
}
return ListTile(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Network Capabilities'),
ElevatedButton(
onPressed: null,
child: Text('Reset to Defaults'),
),
],
),
subtitle: DataTable(
columns: [
DataColumn(label: Text('Network')),
DataColumn(label: Text('React')),
DataColumn(label: Text('Reshare')),
DataColumn(label: Text('Comment')),
],
rows: rows,
));
}
}

View file

@ -61,8 +61,7 @@ class _SignInScreenState extends State<SignInScreen> {
void _updateSignInButtonStatus() {
setState(() {
signInButtonEnabled = switch (oauthType) {
usernamePasswordType =>
serverNameController.text.isNotEmpty &&
usernamePasswordType => serverNameController.text.isNotEmpty &&
usernameController.text.isNotEmpty &&
passwordController.text.isNotEmpty,
oauthType => serverNameController.text.isNotEmpty,
@ -141,249 +140,259 @@ class _SignInScreenState extends State<SignInScreen> {
child: Form(
key: formKey,
child: Center(
child: ListView(
children: [
Center(
child: DropdownButton<String>(
value: authType,
items: authTypes
.map(
(a) => DropdownMenuItem(value: a, child: Text(a)))
.toList(),
onChanged: existingAccount
? null
: (value) {
if (existingAccount) {
buildSnackbar(context,
"Can't change the type on an existing account");
return;
}
authType = value!;
setState(() {});
}),
),
const VerticalPadding(),
TextFormField(
autocorrect: false,
readOnly: existingAccount,
autovalidateMode: AutovalidateMode.onUserInteraction,
controller: serverNameController,
validator: (value) =>
isFQDN(value ?? '') ? null : 'Not a valid server name',
decoration: InputDecoration(
hintText: 'Server Name (friendica.example.com)',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme
.of(context)
.colorScheme
.background,
),
borderRadius: BorderRadius.circular(5.0),
),
labelText: 'Server Name',
),
),
const VerticalPadding(),
if (!showUsernameAndPasswordFields) ...[
Text(
existingAccount
? 'Configured to sign in as user ${existingProfile
?.handle}'
: 'Relatica will open the requested Friendica site in a web browser where you will be asked to authorize this client.',
softWrap: true,
child: AutofillGroup(
child: ListView(
children: [
Center(
child: DropdownButton<String>(
value: authType,
items: authTypes
.map((a) =>
DropdownMenuItem(value: a, child: Text(a)))
.toList(),
onChanged: existingAccount
? null
: (value) {
if (existingAccount) {
buildSnackbar(context,
"Can't change the type on an existing account");
return;
}
authType = value!;
setState(() {});
}),
),
const VerticalPadding(),
],
if (showUsernameAndPasswordFields) ...[
TextFormField(
autocorrect: false,
readOnly: existingAccount,
autovalidateMode: AutovalidateMode.onUserInteraction,
controller: usernameController,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null) {
return null;
}
controller: serverNameController,
validator: (value) =>
isFQDN(value ?? '') ? null : 'Not a valid server name',
decoration: InputDecoration(
hintText: 'Server Name (friendica.example.com)',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.background,
),
borderRadius: BorderRadius.circular(5.0),
),
labelText: 'Server Name',
),
),
const VerticalPadding(),
if (!showUsernameAndPasswordFields) ...[
Text(
existingAccount
? 'Configured to sign in as user ${existingProfile?.handle}'
: 'Relatica will open the requested Friendica site in a web browser where you will be asked to authorize this client.',
softWrap: true,
),
const VerticalPadding(),
],
if (showUsernameAndPasswordFields) ...[
TextFormField(
readOnly: existingAccount,
autovalidateMode: AutovalidateMode.onUserInteraction,
autofillHints: const [AutofillHints.username],
controller: usernameController,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null) {
return null;
}
if (value.contains('@')) {
return isEmail(value)
if (value.contains('@')) {
final properFormat = isEmail(value);
if (!properFormat) {
return 'Not a valid Friendica Account Address';
}
final elements = value.split('@');
if (elements.last != serverNameController.text) {
return 'Server name must match above field.\nUsername should be the *Friendica* username.\nThis is not the email address of the account.';
}
return null;
}
return isAlphanumeric(value.replaceAll('-', ''))
? null
: 'Not a valid Friendica Account Address';
}
return isAlphanumeric(value.replaceAll('-', ''))
? null
: 'Username should be alpha-numeric';
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.alternate_email),
hintText: 'Username (user@example.com)',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme
.of(context)
.colorScheme
.background,
: 'Username should be alpha-numeric';
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.alternate_email),
hintText:
'Your username on the server (not email address)',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.background,
),
borderRadius: BorderRadius.circular(5.0),
),
borderRadius: BorderRadius.circular(5.0),
labelText: 'Username',
),
labelText: 'Username',
),
),
const VerticalPadding(),
TextFormField(
readOnly: existingAccount,
obscureText: hidePassword,
controller: passwordController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password field cannot be empty';
}
const VerticalPadding(),
TextFormField(
readOnly: existingAccount,
obscureText: hidePassword,
controller: passwordController,
autofillHints: const [AutofillHints.password],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password field cannot be empty';
}
return null;
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.password),
suffixIcon: IconButton(
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
icon: hidePassword
? const Icon(Icons.remove_red_eye_outlined)
: const Icon(Icons.remove_red_eye),
),
hintText: 'Password',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme
.of(context)
.colorScheme
.background,
return null;
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.password),
suffixIcon: IconButton(
onPressed: () {
setState(() {
hidePassword = !hidePassword;
});
},
icon: hidePassword
? const Icon(Icons.remove_red_eye_outlined)
: const Icon(Icons.remove_red_eye),
),
borderRadius: BorderRadius.circular(5.0),
hintText: 'Password',
border: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.background,
),
borderRadius: BorderRadius.circular(5.0),
),
labelText: 'Password',
),
labelText: 'Password',
),
),
const VerticalPadding(),
],
signInButtonEnabled
? ElevatedButton(
onPressed: () async => await _signIn(context),
child: const Text('Signin'),
)
: SizedBox(),
const VerticalPadding(),
Text(
'Logged out:',
style: Theme.of(context).textTheme.headlineSmall,
),
loggedOutProfiles.isEmpty
? const Text(
'No logged out profiles',
textAlign: TextAlign.center,
)
: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: 0.5,
)),
child: Column(
children: loggedOutProfiles.map((p) {
return ListTile(
onTap: () {
setCredentials(context, p);
setState(() {});
},
title: Text(p.handle),
subtitle: Text(p.credentials is BasicCredentials
? usernamePasswordType
: oauthType),
trailing: ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(
context,
'Remove login information from app?');
if (confirm ?? false) {
await service.removeProfile(p);
}
setState(() {});
},
child: const Text('Remove'),
),
);
}).toList(),
),
),
const VerticalPadding(),
Text(
'Logged in:',
style: Theme.of(context).textTheme.headlineSmall,
),
loggedInProfiles.isEmpty
? const Text(
'No logged in profiles',
textAlign: TextAlign.center,
)
: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: 0.5,
)),
child: Column(
children: loggedInProfiles.map((p) {
final active = service.loggedIn
? p.id == service.currentProfile.id
: false;
return ListTile(
onTap: () async {
setCredentials(context, p);
setState(() {});
},
title: Text(
p.handle,
style: active
? const TextStyle(
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)
: null,
),
subtitle: Text(
p.credentials is BasicCredentials
? usernamePasswordType
: oauthType,
style: active
? const TextStyle(
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)
: null,
),
trailing: ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(
context, 'Log out account?');
if (confirm == true) {
await getIt<AccountsService>().signOut(p);
setState(() {});
}
},
child: const Text('Sign out'),
),
);
}).toList(),
),
),
const VerticalPadding(),
ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(context,
'Are you sure you want to logout and delete *all* accounts? This cannot be undone.') ??
false;
print(confirm);
if (!confirm) {
return;
}
await getIt<AccountsService>().clearAllProfiles();
},
child: Text('Clear All')),
],
signInButtonEnabled
? ElevatedButton(
onPressed: () async => await _signIn(context),
child: const Text('Signin'),
)
: SizedBox(),
const VerticalPadding(),
Text(
'Logged out:',
style: Theme
.of(context)
.textTheme
.headlineSmall,
),
loggedOutProfiles.isEmpty
? const Text(
'No logged out profiles',
textAlign: TextAlign.center,
)
: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: 0.5,
)),
child: Column(
children: loggedOutProfiles.map((p) {
return ListTile(
onTap: () {
setCredentials(context, p);
setState(() {});
},
title: Text(p.handle),
subtitle: Text(p.credentials is BasicCredentials
? usernamePasswordType
: oauthType),
trailing: ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(context,
'Remove login information from app?');
if (confirm ?? false) {
await service.removeProfile(p);
}
setState(() {});
},
child: const Text('Remove'),
),
);
}).toList(),
),
),
const VerticalPadding(),
Text(
'Logged in:',
style: Theme
.of(context)
.textTheme
.headlineSmall,
),
loggedInProfiles.isEmpty
? const Text(
'No logged in profiles',
textAlign: TextAlign.center,
)
: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: 0.5,
)),
child: Column(
children: loggedInProfiles.map((p) {
final active = service.loggedIn
? p.id == service.currentProfile.id
: false;
return ListTile(
onTap: () async {
setCredentials(context, p);
setState(() {});
},
title: Text(
p.handle,
style: active
? const TextStyle(
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)
: null,
),
subtitle: Text(
p.credentials is BasicCredentials
? usernamePasswordType
: oauthType,
style: active
? const TextStyle(
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic)
: null,
),
trailing: ElevatedButton(
onPressed: () async {
final confirm = await showYesNoDialog(
context, 'Log out account?');
if (confirm == true) {
await getIt<AccountsService>().signOut(p);
setState(() {});
}
},
child: const Text('Sign out'),
),
);
}).toList(),
),
),
],
),
),
),
),
@ -405,8 +414,9 @@ class _SignInScreenState extends State<SignInScreen> {
} else {
switch (authType) {
case usernamePasswordType:
final username = usernameController.text.split('@').first;
creds = BasicCredentials(
username: usernameController.text,
username: username,
password: passwordController.text,
serverName: serverNameController.text);
break;

View file

@ -44,8 +44,9 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
.value;
final blocksManager =
context.watch<ActiveProfileSelector<BlocksManager>>().activeEntry.value;
final body =
connectionManager.getById(widget.userId).fold(onSuccess: (profile) {
final body = connectionManager
.getById(widget.userId, forceUpdate: true)
.fold(onSuccess: (profile) {
final notMyProfile =
getIt<AccountsService>().currentProfile.userId != profile.id;
@ -54,6 +55,7 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
await connectionManager.fullRefresh(profile,
withNotifications: false);
await blocksManager.updateBlock(profile);
setState(() {});
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
@ -158,36 +160,43 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
return <CircleData>{};
});
myCircles.sort((g1, g2) => g1.name.compareTo(g2.name));
final circlesWidgets = myCircles.map((g) {
return CheckboxListTile(
title: Text(g.name),
value: usersCircles.contains(g),
onChanged: (bool? value) async {
if (isUpdating) {
return;
}
onChanged: isUpdating
? null
: (bool? value) async {
if (isUpdating) {
return;
}
final isAdding = value == true;
final confirm = await showYesNoDialog(
context,
isAdding
? 'Add user to ${g.name}'
: 'Remove user from ${g.name}');
if (confirm != true) {
return;
}
setState(() {
isUpdating = true;
});
if (isAdding) {
await manager.addUserToCircle(g, profile);
} else {
await manager.removeUserFromCircle(g, profile);
}
setState(() {
isUpdating = false;
});
},
final isAdding = value == true;
final confirm = await showYesNoDialog(
context,
isAdding
? 'Add user to ${g.name}'
: 'Remove user from ${g.name}');
if (confirm != true) {
return;
}
setState(() {
isUpdating = true;
});
if (isAdding) {
await manager.addUserToCircle(g, profile);
} else {
await manager.removeUserFromCircle(g, profile);
}
if (mounted) {
buildSnackbar(context, "User's Circles Updated");
}
setState(() {
isUpdating = false;
});
},
);
}).toList();
return Column(

View file

@ -2,7 +2,7 @@ import '../../models/connection.dart';
extension ConnectionFriendicaExtensions on Connection {
static Connection fromJson(Map<String, dynamic> json) {
final status = json['following'] == 'true'
final status = json['following']
? ConnectionStatus.youFollowThem
: ConnectionStatus.none;
final name = json['name'] ?? '';

View file

@ -7,7 +7,7 @@ extension ConnectionMastodonExtensions on Connection {
{String defaultServerName = ''}) {
final name = json['display_name'] ?? '';
final id = json['id']?.toString() ?? '';
final profileUrl = Uri.parse(json['url'] ?? '');
var profileUrl = Uri.parse(json['url'] ?? '');
const network = 'Unknown';
final avatar = Uri.tryParse(json['avatar_static'] ?? '') ?? Uri();
final String handleFromJson = json['acct'];
@ -20,11 +20,23 @@ extension ConnectionMastodonExtensions on Connection {
late final String handle;
if (handleFromJson.contains('@')) {
handle = handleFromJson;
final handleElements = handleFromJson.split('@');
if (handleElements.last == 'threads.net') {
profileUrl =
Uri.parse('https://www.threads.net/@${handleElements.first}');
}
} else {
final server = defaultServerName.isNotEmpty
? defaultServerName
: getIt<AccountsService>().currentProfile.serverName;
handle = '$handleFromJson@$server';
if (server == 'threads.net') {
profileUrl = Uri.parse('https://www.threads.net/@$handleFromJson');
}
}
if (profileUrl.scheme == 'did') {
profileUrl = Uri.parse('https://bsky.app/profile/$handleFromJson');
}
return Connection(

View file

@ -1,10 +1,12 @@
import 'package:logging/logging.dart';
import '../../globals.dart';
import '../../models/delivery_data.dart';
import '../../models/engagement_summary.dart';
import '../../models/link_data.dart';
import '../../models/location_data.dart';
import '../../models/timeline_entry.dart';
import '../../models/timeline_network_info.dart';
import '../../models/visibility.dart';
import '../../services/auth_service.dart';
import '../../services/connections_manager.dart';
@ -17,6 +19,7 @@ import 'connection_mastodon_extensions.dart';
import 'hashtag_mastodon_extensions.dart';
import 'link_preview_mastodon_extensions.dart';
import 'media_attachment_mastodon_extension.dart';
import 'timeline_network_info_mastodon_extensions.dart';
final _logger = Logger('TimelineEntryMastodonExtensions');
@ -32,6 +35,10 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
return null;
});
final networkInfo = json['application'] != null
? TimelineNetworkInfoMastodonExtensions.fromJson(json['application'])
: TimelineNetworkInfo.empty;
final connectionManager = getIt<ActiveProfileSelector<ConnectionsManager>>()
.getForProfile(activeProfile)
.fold(
@ -76,6 +83,8 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
final visibilityString = json['visibility'];
if (visibilityString == 'public') {
visibility = Visibility.public();
} else if (visibilityString == 'unlisted') {
visibility = Visibility.unlisted();
} else if (visibilityString == 'private') {
final allowedUserIds =
json['friendica']?['visibility']?['allow_cid'] as List<dynamic>? ??
@ -94,15 +103,16 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
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 = '';
final spoilerText = json['spoiler_text'] ?? '';
final externalLink = json['uri'] ?? '';
final externalLink = switch (networkInfo.network) {
KnownNetworks.bluesky || KnownNetworks.threads => json['url'] ?? '',
_ => json['uri'] ?? '',
};
const actualLocationData = LocationData();
final modificationTimestamp = timestamp;
@ -143,6 +153,15 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
final connection = ConnectionMastodonExtensions.fromJson(json['account']);
connectionManager?.upsertConnection(connection);
final ddj = json['friendica']?['delivery_data'];
final deliveryData = ddj == null
? DeliveryData.empty
: DeliveryData(
total: ddj['delivery_queue_count'] ?? 0,
done: ddj['delivery_queue_done'] ?? 0,
failed: ddj['delivery_queue_failed'] ?? 0,
);
return TimelineEntry(
creationTimestamp: timestamp,
modificationTimestamp: modificationTimestamp,
@ -165,7 +184,9 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
tags: tags,
mediaAttachments: mediaAttachments,
engagementSummary: engagementSummary,
networkInfo: networkInfo,
linkPreviewData: linkPreviewData,
deliveryData: deliveryData,
);
}
}

View file

@ -0,0 +1,74 @@
import '../../models/timeline_network_info.dart';
extension TimelineNetworkInfoMastodonExtensions on TimelineNetworkInfo {
static TimelineNetworkInfo fromJson(Map<String, dynamic> json) {
final String name = json['name'] ?? 'Unknown';
final vapidKey = json['vapid_key'] ?? '';
final nameMainPart = name.split('(').first.trim();
final KnownNetworks network = switch (nameMainPart.toLowerCase()) {
'api' => KnownNetworks.friendica,
'activitypub' => KnownNetworks.activityPub,
'akkoma' => KnownNetworks.pleroma,
'bluesky' => KnownNetworks.bluesky,
'diaspora' => KnownNetworks.diaspora,
'friendica' => KnownNetworks.friendica,
'friendika' => KnownNetworks.friendica,
'gnu social' => KnownNetworks.gnu_social,
'gnusocial' => KnownNetworks.gnu_social,
'hubzilla' => KnownNetworks.hubzilla,
'mastodon' => KnownNetworks.mastodon,
'peertube' => KnownNetworks.peertube,
'pixelfed' => KnownNetworks.pixelfed,
'pleroma' => KnownNetworks.pleroma,
'red' => KnownNetworks.hubzilla,
'redmatrix' => KnownNetworks.hubzilla,
'socialhome' => KnownNetworks.socialhome,
'wordpress' => KnownNetworks.wordpress,
'lemmy' => KnownNetworks.lemmy,
'plume' => KnownNetworks.plume,
'funkwhale' => KnownNetworks.funkwhale,
'nextcloud' => KnownNetworks.nextcloud,
'drupal' => KnownNetworks.drupal,
'firefish' => KnownNetworks.firefish,
'calckey' => KnownNetworks.calckey,
'kbin' => KnownNetworks.kbin,
'threads' => KnownNetworks.threads,
// Clients which are assumed to be interfacing with Friendica if got set as application
// list from blockbot's blockbot_is_fediverse_client
'mastodonandroid' => KnownNetworks.friendica,
'tootdeck-worker' => KnownNetworks.friendica,
'piefed' => KnownNetworks.friendica,
'brighteon' => KnownNetworks.friendica,
'pachli' => KnownNetworks.friendica,
'tusky' => KnownNetworks.friendica,
'mona' => KnownNetworks.friendica,
'mitra' => KnownNetworks.friendica,
'megalodonandroid' => KnownNetworks.friendica,
'fedilab' => KnownNetworks.friendica,
'mastodonapp' => KnownNetworks.friendica,
'toot!' => KnownNetworks.friendica,
'intravnews' => KnownNetworks.friendica,
'pixeldroid' => KnownNetworks.friendica,
'greatnews' => KnownNetworks.friendica,
'protopage' => KnownNetworks.friendica,
'newsfox' => KnownNetworks.friendica,
'vienna' => KnownNetworks.friendica,
'wp-urldetails' => KnownNetworks.friendica,
'husky' => KnownNetworks.friendica,
'activitypub-go-http-client' => KnownNetworks.friendica,
'mobilesafari' => KnownNetworks.friendica,
'mastodon-ios' => KnownNetworks.friendica,
'mastodonpy' => KnownNetworks.friendica,
'techniverse' => KnownNetworks.friendica,
'relatica' => KnownNetworks.friendica,
_ => KnownNetworks.unknown,
};
return TimelineNetworkInfo(
name: name,
vapidKey: vapidKey,
network: network,
);
}
}

View file

@ -8,6 +8,10 @@ extension VisibilityMastodonExtensions on Visibility {
return 'public';
}
if (type == VisibilityType.unlisted) {
return 'unlisted';
}
if (!onComment && hasDetails) {
final circleId =
allowedCircleIds.firstOrNull ?? allowedUserIds.firstOrNull;

View file

@ -78,20 +78,20 @@ class AccountsService extends ChangeNotifier {
FutureResult<Profile, ExecError> signIn(ICredentials credentials,
{bool withNotification = true}) async {
ICredentials? credentialsCache;
final result =
await credentials.signIn().andThenAsync((signedInCredentials) async {
final client =
ProfileClient(Profile.credentialsOnly(signedInCredentials));
credentialsCache = signedInCredentials;
getIt<StatusService>().setStatus(
'Getting user profile from ${signedInCredentials.serverName}');
return await client.getMyProfile();
}).andThenAsync((profileData) async {
}).andThenAsync((profileResult) async {
final profileData = profileResult.$1;
final profile = profileResult.$2;
final loginProfile = Profile(
credentials: credentialsCache!,
credentials: profile.credentials,
username: profileData.name,
serverName: credentialsCache!.serverName,
serverName: profile.credentials.serverName,
avatar: profileData.avatarUrl,
userId: profileData.id,
loggedIn: true,
@ -178,6 +178,14 @@ class AccountsService extends ChangeNotifier {
await _saveStoredLoginState();
}
Future<void> clearAllProfiles() async {
_loggedInProfiles.clear();
_loggedOutProfiles.clear();
_currentProfile = null;
await secretsService.clearCredentials();
notifyListeners();
}
Future<void> _saveStoredLoginState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('active_profile_id', _currentProfile?.id ?? '');

View file

@ -23,6 +23,8 @@ class EntryManagerService extends ChangeNotifier {
final _entries = <String, TimelineEntry>{};
final _parentPostIds = <String, String>{};
final _postNodes = <String, _Node>{};
final _postThreadHashtags = <String, Set<String>>{};
final _postTreeConnections = <String, Set<String>>{};
final Profile profile;
EntryManagerService(this.profile);
@ -61,6 +63,32 @@ class EntryManagerService extends ChangeNotifier {
return Result.ok(_nodeToTreeItem(postNode, profile.userId));
}
Result<List<String>, ExecError> getPostTreeHashtags(String id) {
final postId = _getPostRootNode(id)?.id ?? '';
if (postId.isEmpty) {
return buildErrorResult(
type: ErrorType.notFound,
message: 'Root Post ID not found for $id',
);
}
final hashtags = _postThreadHashtags[postId]?.toList() ?? [];
return Result.ok(hashtags);
}
Result<List<String>, ExecError> getPostTreeConnectionIds(String id) {
final postId = _getPostRootNode(id)?.id ?? '';
if (postId.isEmpty) {
return buildErrorResult(
type: ErrorType.notFound,
message: 'Root Post ID not found for $id',
);
}
final hashtags = _postTreeConnections[postId]?.toList() ?? [];
return Result.ok(hashtags);
}
Result<TimelineEntry, ExecError> getEntryById(String id) {
if (_entries.containsKey(id)) {
return Result.ok(_entries[id]!);
@ -353,6 +381,11 @@ class EntryManagerService extends ChangeNotifier {
if (item.parentId.isEmpty) {
final postNode =
_postNodes.putIfAbsent(item.id, () => _Node(item.id));
final pth = _postThreadHashtags.putIfAbsent(item.id, () => {});
final ptc = _postTreeConnections.putIfAbsent(item.id, () => {});
pth.addAll(item.tags);
ptc.add(item.authorId);
ptc.add(item.parentAuthorId);
postNodesToReturn.add(postNode);
allSeenItems.remove(item);
} else {
@ -364,6 +397,14 @@ class EntryManagerService extends ChangeNotifier {
'Error finding parent ${item.parentId} for entry ${item.id}');
continue;
}
final pth =
_postThreadHashtags.putIfAbsent(parentParentPostId!, () => {});
final ptc =
_postTreeConnections.putIfAbsent(parentParentPostId, () => {});
pth.addAll(item.tags);
ptc.add(item.authorId);
ptc.add(item.parentAuthorId);
final parentPostNode = _postNodes[parentParentPostId]!;
postNodesToReturn.add(parentPostNode);
_parentPostIds[item.id] = parentPostNode.id;

View file

@ -6,10 +6,41 @@ import '../models/exec_error.dart';
import '../models/server_data.dart';
import '../utils/network_utils.dart';
final blueSkyData = ServerData(
domainName: 'bsky.app',
isFediverse: true,
protocols: [
'ATProto',
],
);
final threadsData = ServerData(
domainName: 'threads.net',
isFediverse: true,
protocols: [
'activitypub',
],
);
final threadsWwwData = ServerData(
domainName: 'www.threads.net',
isFediverse: true,
protocols: [
'activitypub',
],
);
const blueskyDomain = 'bsky.app';
const threadsDomain = 'threads.net';
const threadsWwwDomain = 'www.threads.net';
class FediverseServiceValidator {
static const softwareTypeDiaspora = 'diaspora';
final knownServers = <String, ServerData>{};
final knownServers = <String, ServerData>{
threadsDomain: threadsData,
threadsWwwDomain: threadsData,
blueskyDomain: blueSkyData,
};
FutureResult<ServerData, ExecError> getServerData(String url) async {
final uri = Uri.tryParse(url);
@ -37,6 +68,14 @@ class FediverseServiceValidator {
}
static Future<ServerData> refreshServerData(String domainName) async {
if (domainName == threadsDomain) {
return threadsData;
}
if (domainName == threadsWwwDomain) {
return threadsWwwData;
}
final uri = Uri.https(
domainName,
'/.well-known/nodeinfo',

View file

@ -18,6 +18,7 @@ import 'direct_message_service.dart';
import 'feature_version_checker.dart';
import 'follow_requests_manager.dart';
import 'network_status_service.dart';
import 'setting_service.dart';
class NotificationsManager extends ChangeNotifier {
static const itemsPerQuery = 50;
@ -139,7 +140,13 @@ class NotificationsManager extends ChangeNotifier {
if (unread.isNotEmpty) {
final result =
await _loadOlderUnreadNotifications(withListenerNotification);
if (result.getValueOrElse(() => []).isNotEmpty) {
final nonDmAndConnectionNotifications = result
.getValueOrElse(() => [])
.where((n) =>
n.type != NotificationType.follow_request &&
n.type != NotificationType.direct_message)
.toList();
if (nonDmAndConnectionNotifications.isNotEmpty) {
return result;
}
}
@ -264,6 +271,7 @@ class NotificationsManager extends ChangeNotifier {
Future<void> _processNewNotifications(
Iterable<UserNotification> notifications) async {
final groupNotifications = getIt<SettingsService>().notificationGrouping;
final dmsMap = <String, UserNotification>{};
final crMap = <String, UserNotification>{};
final unreadMap = <String, UserNotification>{};
@ -337,10 +345,12 @@ class NotificationsManager extends ChangeNotifier {
..sort();
unread
..addAll(unreadMap.values)
..sort();
..sort(
(n1, n2) => _compareByTypeStatusAndDate(n1, n2, groupNotifications));
read
..addAll(readMap.values)
..sort();
..sort(
(n1, n2) => _compareByTypeStatusAndDate(n1, n2, groupNotifications));
}
FutureResult<List<UserNotification>, ExecError> _loadOlderUnreadNotifications(
@ -436,3 +446,28 @@ PagesManager<List<UserNotification>, String> _buildPageManager(
onRequest: (pd) async =>
await NotificationsClient(profile).getNotifications(pd, includeAll),
);
int _compareByTypeStatusAndDate(
UserNotification n1, UserNotification n2, bool groupNotifications) {
final n1Weight = _notificationTypeToWeight(n1.type);
final n2Weight = _notificationTypeToWeight(n2.type);
if (!groupNotifications || n1Weight == n2Weight) {
return n1.compareTo(n2);
}
return (n2Weight - n1Weight).sign.toInt();
}
num _notificationTypeToWeight(NotificationType type) {
return switch (type) {
NotificationType.follow_request => 1000,
NotificationType.follow => 100,
NotificationType.direct_message => 50,
NotificationType.mention => 10,
NotificationType.status => 4,
NotificationType.reshare => 3,
NotificationType.reblog => 3,
NotificationType.favourite => 2,
NotificationType.unknown => 1,
};
}

View file

@ -41,6 +41,7 @@ class SecretsService {
try {
await _secureStorage.delete(key: _basicProfilesKey);
await _secureStorage.delete(key: _oauthProfilesKey);
profiles.clear();
return Result.ok(profiles);
} catch (e) {
return Result.error(ExecError(

View file

@ -1,8 +1,11 @@
import 'dart:convert';
import 'package:color_blindness/color_blindness.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/settings/network_capabilities_settings.dart';
import '../utils/theme_mode_extensions.dart';
class SettingsService extends ChangeNotifier {
@ -21,6 +24,26 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
var _notificationGrouping = true;
bool get notificationGrouping => _notificationGrouping;
set notificationGrouping(bool value) {
_notificationGrouping = value;
_prefs.setBool(_notificationGroupingKey, _notificationGrouping);
notifyListeners();
}
var _spoilerHidingEnabled = true;
bool get spoilerHidingEnabled => _spoilerHidingEnabled;
set spoilerHidingEnabled(bool value) {
_spoilerHidingEnabled = value;
_prefs.setBool(_spoilerHidingEnabledKey, _spoilerHidingEnabled);
notifyListeners();
}
var _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
@ -51,15 +74,30 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
NetworkCapabilitiesSettings _networkCapabilities =
NetworkCapabilitiesSettings.defaultSettings();
NetworkCapabilitiesSettings get networkCapabilities => _networkCapabilities;
set networkCapabilities(NetworkCapabilitiesSettings updatedCapabilities) {
_networkCapabilities = updatedCapabilities;
final jsonString = jsonEncode(_networkCapabilities.toJson());
_prefs.setString(_networkCapabilitiesKey, jsonString);
notifyListeners();
}
Future<void> initialize() async {
if (_initialized) {
return;
}
_prefs = await SharedPreferences.getInstance();
_lowBandwidthMode = _prefs.getBool(_lowBandwidthModeKey) ?? false;
_notificationGrouping = _prefs.getBool(_notificationGroupingKey) ?? true;
_spoilerHidingEnabled = _prefs.getBool(_spoilerHidingEnabledKey) ?? true;
_themeMode = ThemeModeExtensions.parse(_prefs.getString(_themeModeKey));
_colorBlindnessType = _colorBlindnessTypeFromPrefs(_prefs);
_logLevel = _levelFromPrefs(_prefs);
_networkCapabilities = _networkCapabilitiesFromPrefs(_prefs);
_initialized = true;
}
}
@ -68,6 +106,9 @@ const _lowBandwidthModeKey = 'LowBandwidthMode';
const _themeModeKey = 'ThemeMode';
const _colorBlindnessTestingModeKey = 'ColorBlindnessTestingMode';
const _logLevelKey = 'LogLevel';
const _networkCapabilitiesKey = 'NetworkCapabilities';
const _notificationGroupingKey = 'NotificationGrouping';
const _spoilerHidingEnabledKey = 'SpoilerHidingEnabled';
ColorBlindnessType _colorBlindnessTypeFromPrefs(SharedPreferences prefs) {
final cbString = prefs.getString(_colorBlindnessTestingModeKey);
@ -80,6 +121,18 @@ ColorBlindnessType _colorBlindnessTypeFromPrefs(SharedPreferences prefs) {
);
}
NetworkCapabilitiesSettings _networkCapabilitiesFromPrefs(
SharedPreferences prefs) {
final ncString = prefs.getString(_networkCapabilitiesKey);
if (ncString?.isEmpty ?? true) {
return NetworkCapabilitiesSettings.defaultSettings();
}
final List<dynamic> json = jsonDecode(ncString!);
final nc = NetworkCapabilitiesSettings.fromJson(json);
return nc;
}
Level _levelFromPrefs(SharedPreferences prefs) {
final levelString = prefs.getString(_logLevelKey);
return switch (levelString) {

View file

@ -40,8 +40,12 @@ class OffsetDateTimeUtils {
}
class ElapsedDateUtils {
static String epochSecondsToString(int epochSeconds) {
final epoch = DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000);
static String elapsedTimeStringFromEpochSeconds(int epochSeconds) {
return epochMilliSecondsToString(epochSeconds * 1000);
}
static String epochMilliSecondsToString(int epochMilliSeconds) {
final epoch = DateTime.fromMillisecondsSinceEpoch(epochMilliSeconds);
final elapsed = DateTime.now().difference(epoch);
if (elapsed.inDays > 0) {
return '${elapsed.inDays} days ago';
@ -57,6 +61,16 @@ class ElapsedDateUtils {
return 'seconds ago';
}
static Duration elapsedTimeFromEpochSeconds(int epochSeconds) {
return elapsedTimeFromEpochMilliseconds(epochSeconds * 1000);
}
static Duration elapsedTimeFromEpochMilliseconds(int epochMilliseconds) {
final epoch = DateTime.fromMillisecondsSinceEpoch(epochMilliseconds);
final elapsed = DateTime.now().difference(epoch);
return elapsed;
}
}
const _separator = '_';

View file

@ -0,0 +1,96 @@
import '../globals.dart';
import '../models/timeline_entry.dart';
import '../models/timeline_network_info.dart';
import '../models/visibility.dart';
import '../services/setting_service.dart';
import 'known_network_extensions.dart';
class InteractionCapabilityResult {
final bool canDo;
final String reason;
const InteractionCapabilityResult(
{required this.canDo, required this.reason});
}
extension InteractionAvailabilityExtension on TimelineEntry {
InteractionCapabilityResult getCanComment() {
final settingsService = getIt<SettingsService>();
final nc = settingsService.networkCapabilities
.getCapabilities(networkInfo.network);
if (!nc.comment) {
return InteractionCapabilityResult(
canDo: false,
reason:
"User disabled commenting on ${networkInfo.network.labelName} items. Go into settings to change.",
);
}
return const InteractionCapabilityResult(
canDo: true,
reason: "Can comment on item",
);
}
InteractionCapabilityResult getCanReact() {
final settingsService = getIt<SettingsService>();
final nc = settingsService.networkCapabilities
.getCapabilities(networkInfo.network);
if (!nc.react) {
return InteractionCapabilityResult(
canDo: false,
reason:
"User disabled reacting on ${networkInfo.network.labelName} items. Go into settings to change.",
);
}
return const InteractionCapabilityResult(
canDo: true,
reason: "Can react on item",
);
}
InteractionCapabilityResult getIsReshareable(bool isMine) {
if (isMine) {
return const InteractionCapabilityResult(
canDo: false,
reason: "Can't reshare your own post",
);
}
if (networkInfo.network == KnownNetworks.bluesky) {
return const InteractionCapabilityResult(
canDo: false,
reason:
"Resharing of Bluesky posts through the API isn't supported by Friendica.",
);
}
final settingsService = getIt<SettingsService>();
final nc = settingsService.networkCapabilities
.getCapabilities(networkInfo.network);
if (!nc.reshare) {
return InteractionCapabilityResult(
canDo: false,
reason:
"User disabled resharing ${networkInfo.network.labelName} items. Go into settings to change.",
);
}
if (visibility.type == VisibilityType.public ||
visibility.type == VisibilityType.unlisted) {
return const InteractionCapabilityResult(
canDo: true,
reason: "Can reshare item",
);
}
return const InteractionCapabilityResult(
canDo: false,
reason: "Can't reshare private items",
);
}
}

View file

@ -0,0 +1,63 @@
import '../models/timeline_network_info.dart';
extension KnownNetworkExtensions on KnownNetworks {
String get labelName => switch (this) {
KnownNetworks.activityPub => 'ActivityPub',
KnownNetworks.bluesky => 'Bluesky',
KnownNetworks.calckey => 'Calckey',
KnownNetworks.diaspora => 'Diaspora',
KnownNetworks.drupal => 'Drupal',
KnownNetworks.firefish => 'Firefish',
KnownNetworks.friendica => 'Friendica',
KnownNetworks.funkwhale => 'Funkwhale',
KnownNetworks.gnu_social => 'GNU Social',
KnownNetworks.hubzilla => 'Hubzilla',
KnownNetworks.kbin => 'Kbin',
KnownNetworks.lemmy => 'Lemmy',
KnownNetworks.mastodon => 'Mastodon',
KnownNetworks.nextcloud => 'Nextcloud',
KnownNetworks.peertube => 'PeerTube',
KnownNetworks.pixelfed => 'Pixelfed',
KnownNetworks.pleroma => 'Pleroma',
KnownNetworks.plume => 'Plume',
KnownNetworks.red => 'Red',
KnownNetworks.redmatrix => 'RedMatrix',
KnownNetworks.socialhome => 'Socialhome',
KnownNetworks.threads => 'Threads',
KnownNetworks.wordpress => 'WordPress',
KnownNetworks.unknown => 'Unknown',
};
String get forkAwesomeUnicode => switch (this) {
KnownNetworks.activityPub => '\uf2f2',
KnownNetworks.bluesky => '\uf111',
KnownNetworks.calckey => '\uf1ec',
KnownNetworks.diaspora => '\uf2e5',
KnownNetworks.drupal => '\uf1a9',
KnownNetworks.firefish => '\uf06d',
KnownNetworks.friendica => '\uf2e6',
KnownNetworks.funkwhale => '\uf339',
KnownNetworks.gnu_social => '\uf2e7',
KnownNetworks.hubzilla => '\uf2eb',
KnownNetworks.kbin => '\uf058',
KnownNetworks.lemmy => '\uf0c0',
KnownNetworks.mastodon => '\uf2e1',
KnownNetworks.nextcloud => '\uf307',
KnownNetworks.peertube => '\uf2e4',
KnownNetworks.pixelfed => '\uf314',
KnownNetworks.pleroma => '\uf324',
KnownNetworks.plume => '\uf356',
KnownNetworks.red => '\uf2eb',
KnownNetworks.redmatrix => '\uf2eb',
KnownNetworks.socialhome => '\uf2ec',
KnownNetworks.threads => '\uf16d',
KnownNetworks.wordpress => '\uf19a',
KnownNetworks.unknown => '\uf059',
};
}
extension TimelineNetworkInfoExtensions on TimelineNetworkInfo {
String get labelName => network.labelName;
String get forkAwesomeUnicode => network.forkAwesomeUnicode;
}

View file

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
@ -80,10 +81,16 @@ class _CachedResponse {
other is _CachedResponse &&
runtimeType == other.runtimeType &&
requestType == other.requestType &&
requestUri == other.requestUri;
requestUri == other.requestUri &&
const MapEquality().equals(requestBody, other.requestBody) &&
const MapEquality().equals(headers, other.headers);
@override
int get hashCode => requestType.hashCode ^ requestUri.hashCode;
int get hashCode =>
requestType.hashCode ^
requestUri.hashCode ^
const MapEquality().hash(requestBody) ^
const MapEquality().hash(headers);
}
class _ExpiringRequestCache {
@ -121,7 +128,7 @@ class _ExpiringRequestCache {
late final http.Response response;
if (_responses.containsKey(requestStub)) {
print('Returning cached response for $type => $url');
_logger.fine('Returning cached response for $type => $url');
response = _responses[requestStub]?.response ?? http.Response('', 555);
} else {
final request = RelaticaUserAgentHttpClient().get(

View file

@ -950,7 +950,7 @@ packages:
source: hosted
version: "2.1.0"
package_info_plus:
dependency: "direct main"
dependency: transitive
description:
name: package_info_plus
sha256: "7e76fad405b3e4016cd39d08f455a4eb5199723cf594cd1b8916d47140d93017"

View file

@ -2,7 +2,7 @@ name: relatica
description: A mobile and desktop client for interacting with the Friendica social network
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 0.10.1
version: 0.11.0
environment:
sdk: '>=3.0.0 <4.0.0'
@ -38,7 +38,6 @@ dependencies:
network_to_file_image: ^4.0.1
objectbox: ^2.3.1
objectbox_flutter_libs: ^2.3.1
package_info_plus: ^4.2.0
path: ^1.8.2
path_provider: ^2.0.11
provider: ^6.0.4
@ -69,7 +68,11 @@ flutter:
uses-material-design: true
assets:
- icon/relatica_logo.svg
fonts:
- family: ForkAwesome
fonts:
- asset: fonts/forkawesome-webfont.ttf
parts:
uet-lms:
source: .
@ -81,6 +84,7 @@ parts:
stage-packages:
- libsecret-1-0
- libjsoncpp1
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg