Merge branch 'main' into deprecation-and-library-upgrade-fixes

This commit is contained in:
Hank Grabowski 2024-07-20 12:08:45 +03:00
commit d2732b5a44
59 changed files with 1312 additions and 153 deletions

View file

@ -1,64 +1,163 @@
# Relatica Change Log
## Version 0.12.0 (beta)
* Changes
* Use the network data in the friendica extensions rather than the application data if available since the app data
is the app name.
* Fixes
* New Features
## 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
* Adds Relatica User Agent string to API requests so that Friendica servers running >2023.06
with blockbot enabled will allow requests.
* Adds version string to the settings screen to help users identify version installed
## Version 0.10.0 (beta)
* Changes
* Add user count in the Circles Management screen
## Version 0.9.0 (beta)
* Changes
* Sign in screen has a better flow and layout to make it less confusing
* Notifications screen has a cleaner look when no notifications exist
* Timeline screens have a cleaner look when no posts exist
* Clicking anywhere on a post/comment in the status search results will open post. The "Go To Post/Comment" menu
* Clicking anywhere on a post/comment in the status search results will open post. The "Go To
Post/Comment" menu
option is still there.
* Drag down to refresh timeline will clear and reload the timeline from scratch after a warning.
* Fixes
* Search screen bug with initial search not updating screen correctly addressed
* Multiple images showing up for the same post has been fixed for D* reshares and ActivityPub posts with link
* Multiple images showing up for the same post has been fixed for D* reshares and ActivityPub
posts with link
previews.
that embedded the same image as an image attachment.
* Capitalization in ALT Text field editors should be sentence now.
* Maximum thread rendering depth is set to 5.
* New Features
* When a user clicks on a tag in a post it opens the search onto their local server not the original post/comment's
* When a user clicks on a tag in a post it opens the search onto their local server not the
original post/comment's
server
* Log entry viewer screen with ability to filter, export individual entries, or the entire table to a JSON file for
* Log entry viewer screen with ability to filter, export individual entries, or the entire table
to a JSON file for
helping with debugging.
## Version 0.8.0 (beta)
* Changes
* "Groups" have been renamed "Circles" to match the upcoming Friendica release nomenclature change.
* Timeline selector has now been merged into one big list with a divider between the standard types and the circles
* User Profile screen buttons have been rearranged so that (un)block and (un)follow are on their own line
* "Groups" have been renamed "Circles" to match the upcoming Friendica release nomenclature
change.
* Timeline selector has now been merged into one big list with a divider between the standard
types and the circles
* User Profile screen buttons have been rearranged so that (un)block and (un)follow are on their
own line
* Notifications processing has been streamlined again, especially for older notifications
* Contacts information is updated as the data comes in (may need to pull this out since it can temporarily show
* Contacts information is updated as the data comes in (may need to pull this out since it can
temporarily show
incorrect connection information before the followers mapping occurs)
* Link preview has a new more efficient layout with the photo on top and the text caption underneath
* Link preview has a new more efficient layout with the photo on top and the text caption
underneath
* Sign-in screen has been changed to be more user-friendly
* Start-up splash screen has more prompts to let users know what is happening and timeouts when trying to
communicate with the servers. If nothing is logged in it drops to the sign in screen rather than staying on splash
* Start-up splash screen has more prompts to let users know what is happening and timeouts when
trying to
communicate with the servers. If nothing is logged in it drops to the sign in screen rather
than staying on splash
screen.
* Streamlined the visibility selection in the post/comment editor
* Interactions toolbar has been streamlined. Navigating to the screen with details on who liked/reshared etc. is now
in menu and clicking in any free space in the post card area (except around images/videos since that is
* Interactions toolbar has been streamlined. Navigating to the screen with details on who
liked/reshared etc. is now
in menu and clicking in any free space in the post card area (except around images/videos
since that is
technically part of a media carousel control).
* When saving images on mobile it writes to the photo gallery than the files area.
* Fixes
* Adding/removing of users from Circles properly reflects on profile screen (before needed to navigate away and back
* Adding/removing of users from Circles properly reflects on profile screen (before needed to
navigate away and back
to get it to appear)
* Multiclicking on notifications will not cause multiple navigations to that post/comment
* Multiclicking on post/comment creation will not cause multiple creation events
* Loading newer notifications works.
* Changing profiles when on the Contacts or Search screen now properly reflect the change
* Fix privacy levels on response to Mastodon direct messages. Previously it would expand the privacy to include
* Fix privacy levels on response to Mastodon direct messages. Previously it would expand the
privacy to include
followers
* Image/video size in post/comment viewer doesn't overflow the boundaries of the card
* Unresharing a post doesn't cause it to disappear
* Image viewer screen has better fill and image text appears after clicking ALT button rather than as a caption
* Resharing of comments in Friendica is spotty through the UI and even more so through the API. It has been removed
* Image viewer screen has better fill and image text appears after clicking ALT button rather
than as a caption
* Resharing of comments in Friendica is spotty through the UI and even more so through the API.
It has been removed
for the time being
* PNG images always displayed very low res thumbnails that Friendica generates. PNGs now always load the full size
* PNG images always displayed very low res thumbnails that Friendica generates. PNGs now always
load the full size
image, for previews and full views
* New Features
* Can click on the visibility icon on posts to get a dialog box listing the visibility of the post/comment
* Can click on the visibility icon on posts to get a dialog box listing the visibility of the
post/comment
## Version 0.7.2 (beta)

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

@ -6,12 +6,12 @@ For more information about the current beta testing program
# Latest Binaries:
* Android v0.9.0 Is available by invitation through Play Store beta (please see me for access)
or [this self-installable ZIP file](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.9.0/relatica_v0.9.0.apk.zip)
* iPhone/iPad v0.9.0: This is only available through TestFlight. Please contact me for access.
* [Windows (Intel) v0.9.0](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.9.0/relatica_v0.9.0_win_x64.zip)
* macOS v0.9.0:This is only available through TestFlight. Please contact me for access.
* [Linux v0.9.0 (tested on Ubuntu 20 and 22)](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.9.0/relatica_v0.9.0_linux_x64.zip)
* Android v0.11.0 Is available by invitation through Play Store beta (please see me for access)
or [this self-installable ZIP file](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.11.0/relatica_v0.11.0.apk.zip)
* iPhone/iPad v0.11.0: This is only available through TestFlight. Please contact me for access.
* [Windows (Intel) v0.11.0](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.11.0/relatica_v0.11.0_win_x64.zip)
* macOS v0.11.0:This is only available through TestFlight. Please contact me for access.
* [Linux v0.11.0 (tested on Ubuntu 20 and 22)](https://mysocialportal-relatica.nyc3.cdn.digitaloceanspaces.com/v0.11.0/relatica_v0.11.0_linux_x64.zip)
## Mobile

View file

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
<string>12.0</string>
</dict>
</plist>

View file

@ -166,7 +166,7 @@ SPEC CHECKSUMS:
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_file_dialog: 4c014a45b105709a27391e266c277d7e588e9299
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_web_auth_2: 051cf9f5dc366f31b5dcc4e2952c2b954767be8a
@ -192,4 +192,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 1df1bb3ed89ef4be6115286519e24a9fad12e640
COCOAPODS: 1.14.2
COCOAPODS: 1.15.2

View file

@ -157,7 +157,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

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

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import '../globals.dart';
import '../services/auth_service.dart';
class LoginAwareCachedNetworkImage extends StatelessWidget {
@ -22,12 +23,12 @@ class LoginAwareCachedNetworkImage extends StatelessWidget {
Widget build(BuildContext context) {
final profile = context.watch<AccountsService>().currentProfile;
Map<String, String>? headers;
Map<String, String> headers = {'user-agent': userAgent};
try {
final imageServer = Uri.parse(imageUrl).host;
if (imageServer == profile.serverName) {
headers = {'Authorization': profile.credentials.authHeaderValue};
headers['Authorization'] = profile.credentials.authHeaderValue;
}
} catch (e) {
_logger.severe('Error Parsing ImageURL: $e');

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,28 @@
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) {
const genericNetworks = [KnownNetworks.unknown, KnownNetworks.activityPub];
final networkText =
genericNetworks.contains(info.network) ? 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>
@ -717,6 +725,7 @@ class RemoteFileClient extends FriendicaClient {
url,
headers: {
'Authorization': _profile.credentials.authHeaderValue,
'user-agent': userAgent,
},
);
@ -742,6 +751,7 @@ class RemoteFileClient extends FriendicaClient {
final postUri = Uri.parse('https://$serverName/api/friendica/photo/create');
final request = http.MultipartRequest('POST', postUri);
request.headers['Authorization'] = _profile.credentials.authHeaderValue;
request.headers['user-agent'] = userAgent;
if (usePhpDebugging) {
request.headers['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/';
}
@ -777,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

@ -19,6 +19,9 @@ final platformIsDesktop = !platformIsMobile;
final useImagePicker = kIsWeb || platformIsMobile;
String appVersion = '';
String userAgent = '';
const usePhpDebugging = false;
const maxViewPortalHeight = 750.0;

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:media_kit/media_kit.dart';
import 'package:multi_trigger_autocomplete/multi_trigger_autocomplete.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'app_theme.dart';
@ -43,6 +44,7 @@ void main() async {
await fixLetsEncryptCertOnOldAndroid();
await dependencyInjectionInitialization();
await setupPackageInfoAndUserAgent();
// TODO Add back Device Preview once supported in Flutter 3.22+
// runApp(DevicePreview(
@ -52,6 +54,12 @@ void main() async {
runApp(const App());
}
Future<void> setupPackageInfoAndUserAgent() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appVersion = packageInfo.version;
userAgent = 'Relatica/$appVersion';
}
class App extends StatelessWidget {
const App({super.key});

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,69 @@
enum KnownNetworks {
activityPub,
bluesky,
calckey,
diaspora,
drupal,
firefish,
friendica,
funkwhale,
gnu_social,
hometown,
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 {
@ -28,11 +29,15 @@ class SettingsScreen extends StatelessWidget {
child: ResponsiveMaxWidth(
child: ListView(
children: [
buildVersionString(),
buildLowBandwidthWidget(settings),
buildNotificationGroupingWidget(settings),
buildSpoilerHidingEnabledWidget(settings),
buildThemeWidget(settings),
if (!kReleaseMode) buildColorBlindnessTestSettings(settings),
buildClearCaches(context),
buildLogPanel(context, settings),
const NetworkCapabilitiesWidget(),
],
),
),
@ -40,6 +45,18 @@ class SettingsScreen extends StatelessWidget {
));
}
Widget buildVersionString() {
return Center(
child: Text(
'Relatica $appVersion',
style: const TextStyle(
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold,
),
),
);
}
Widget buildLowBandwidthWidget(SettingsService settings) {
return ListTile(
title: const Text('Low bandwidth mode'),
@ -52,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:'),
@ -125,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

@ -29,7 +29,7 @@ class SplashScreen extends StatelessWidget {
SvgPicture.asset('icon/relatica_logo.svg', width: 128),
const VerticalPadding(),
Text(
'Relatica',
'Relatica $appVersion',
style: Theme.of(context).textTheme.headlineLarge,
),
const VerticalPadding(),

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,8 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
return null;
});
final networkInfo = TimelineNetworkInfoMastodonExtensions.fromJson(json);
final connectionManager = getIt<ActiveProfileSelector<ConnectionsManager>>()
.getForProfile(activeProfile)
.fold(
@ -76,6 +81,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 +101,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 +151,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 +182,9 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
tags: tags,
mediaAttachments: mediaAttachments,
engagementSummary: engagementSummary,
networkInfo: networkInfo,
linkPreviewData: linkPreviewData,
deliveryData: deliveryData,
);
}
}

View file

@ -0,0 +1,52 @@
import '../../models/timeline_network_info.dart';
extension TimelineNetworkInfoMastodonExtensions on TimelineNetworkInfo {
static TimelineNetworkInfo fromJson(Map<String, dynamic> json) {
final String? applicationName = json['application']?['name'];
final String? name = json['friendica']?['network'] ?? applicationName;
if (name == null) {
return TimelineNetworkInfo.empty;
}
final vapidKey = json['vapid_key'] ?? '';
final nameMainPart = name.split('(').first.trim();
final KnownNetworks network = switch (nameMainPart.toLowerCase()) {
'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,
'hometown' => KnownNetworks.hometown,
'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,
_ => name.contains('(AP)')
? KnownNetworks.activityPub
: 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,65 @@
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.hometown => 'Hometown',
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.hometown => '\uf2e1',
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';
@ -19,6 +20,18 @@ enum _RequestType {
const _expireDuration = Duration(seconds: 2);
class RelaticaUserAgentHttpClient extends http.BaseClient {
final http.Client _inner;
RelaticaUserAgentHttpClient() : _inner = http.Client();
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
request.headers['user-agent'] = userAgent;
return _inner.send(request);
}
}
class _CachedResponse {
final _RequestType requestType;
final Uri requestUri;
@ -68,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 {
@ -109,10 +128,10 @@ 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 = http.get(
final request = RelaticaUserAgentHttpClient().get(
url,
headers: headers,
);
@ -184,7 +203,7 @@ FutureResult<String, ExecError> postUrl(
requestHeaders['Cookie'] = 'XDEBUG_SESSION=PHPSTORM;path=/';
}
try {
final request = http.post(
final request = RelaticaUserAgentHttpClient().post(
url,
headers: requestHeaders,
body: jsonEncode(body),
@ -215,7 +234,7 @@ FutureResult<String, ExecError> putUrl(
}) async {
_logger.fine('PUT: $url \n Body: $body');
try {
final request = http.put(
final request = RelaticaUserAgentHttpClient().put(
url,
headers: headers,
body: jsonEncode(body),
@ -246,7 +265,7 @@ FutureResult<String, ExecError> deleteUrl(
}) async {
_logger.fine('DELETE: $url');
try {
final request = http.delete(
final request = RelaticaUserAgentHttpClient().delete(
url,
headers: headers,
body: jsonEncode(body),

View file

@ -1,11 +1,11 @@
import 'dart:convert';
import 'package:html/parser.dart';
import 'package:http/http.dart' as http;
import 'package:result_monad/result_monad.dart';
import '../models/exec_error.dart';
import '../models/link_preview_data.dart';
import 'network_utils.dart';
const ogTitleKey = 'og:title';
const ogDescriptionKey = 'og:description';
@ -35,7 +35,7 @@ FutureResult<LinkPreviewData, ExecError> getLinkPreview(String url) async {
FutureResult<List<MapEntry<String, String>>, dynamic> _getOpenGraphData(
String url) async {
return runCatchingAsync<List<MapEntry<String, String>>>(() async {
final response = await http.get(Uri.parse(url));
final response = await RelaticaUserAgentHttpClient().get(Uri.parse(url));
if (response.statusCode != 200) {
return buildErrorResult(
type: ErrorType.serverError,

View file

@ -130,4 +130,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 137ddf7b4dbe5a83427ebf04ae8dea674cfd87fa
COCOAPODS: 1.14.2
COCOAPODS: 1.15.2

View file

@ -204,7 +204,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
33CC10EC2044A3C60003C045 = {

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

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.0
version: 0.12.0+7
environment:
sdk: '>=3.2.0 <4.0.0'
@ -69,6 +69,10 @@ flutter:
uses-material-design: true
assets:
- icon/relatica_logo.svg
fonts:
- family: ForkAwesome
fonts:
- asset: fonts/forkawesome-webfont.ttf
parts:
uet-lms:
@ -81,6 +85,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