mirror of
https://gitlab.com/mysocialportal/relatica
synced 2024-10-18 12:23:31 +00:00
Merge branch 'main' into deprecation-and-library-upgrade-fixes
This commit is contained in:
commit
d2732b5a44
59 changed files with 1312 additions and 153 deletions
137
CHANGELOG.md
137
CHANGELOG.md
|
@ -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
3
devtools_options.yaml
Normal 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:
|
BIN
fonts/forkawesome-webfont.ttf
Normal file
BIN
fonts/forkawesome-webfont.ttf
Normal file
Binary file not shown.
12
install.md
12
install.md
|
@ -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
|
||||
|
||||
|
|
|
@ -21,6 +21,6 @@
|
|||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>11.0</string>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -157,7 +157,7 @@
|
|||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1430;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
28
lib/controls/timeline/timeline_network_info_control.dart
Normal file
28
lib/controls/timeline/timeline_network_info_control.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>>()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,9 @@ final platformIsDesktop = !platformIsMobile;
|
|||
|
||||
final useImagePicker = kIsWeb || platformIsMobile;
|
||||
|
||||
String appVersion = '';
|
||||
String userAgent = '';
|
||||
|
||||
const usePhpDebugging = false;
|
||||
|
||||
const maxViewPortalHeight = 750.0;
|
||||
|
|
|
@ -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});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'],
|
||||
|
|
17
lib/models/delivery_data.dart
Normal file
17
lib/models/delivery_data.dart
Normal 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;
|
||||
}
|
121
lib/models/settings/network_capabilities_settings.dart
Normal file
121
lib/models/settings/network_capabilities_settings.dart
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
69
lib/models/timeline_network_info.dart
Normal file
69
lib/models/timeline_network_info.dart
Normal 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;
|
||||
}
|
|
@ -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) ||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -85,7 +85,7 @@ class MessagesScreen extends StatelessWidget {
|
|||
style: style,
|
||||
),
|
||||
trailing: Text(
|
||||
ElapsedDateUtils.epochSecondsToString(
|
||||
ElapsedDateUtils.elapsedTimeStringFromEpochSeconds(
|
||||
thread.messages.last.createdAt),
|
||||
style: style,
|
||||
),
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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'] ?? '';
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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 ?? '');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 = '_';
|
||||
|
|
96
lib/utils/interaction_availability_util.dart
Normal file
96
lib/utils/interaction_availability_util.dart
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
65
lib/utils/known_network_extensions.dart
Normal file
65
lib/utils/known_network_extensions.dart
Normal 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;
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -130,4 +130,4 @@ SPEC CHECKSUMS:
|
|||
|
||||
PODFILE CHECKSUM: 137ddf7b4dbe5a83427ebf04ae8dea674cfd87fa
|
||||
|
||||
COCOAPODS: 1.14.2
|
||||
COCOAPODS: 1.15.2
|
||||
|
|
|
@ -204,7 +204,7 @@
|
|||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 1430;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
33CC10EC2044A3C60003C045 = {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue