mirror of
https://gitlab.com/mysocialportal/relatica
synced 2024-10-18 15:53:32 +00:00
Merge branch 'status-editing' into 'main'
Status editing See merge request mysocialportal/relatica!23
This commit is contained in:
commit
3f6f04c9e7
19 changed files with 1232 additions and 92 deletions
|
@ -66,14 +66,15 @@ class _GallerySelectorControlState extends State<GallerySelectorControl> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
setState(() async {
|
final confirm =
|
||||||
final confirm = await showYesNoDialog(
|
await showYesNoDialog(context, 'Remove image from post?');
|
||||||
context, 'Remove image from post?');
|
|
||||||
if (confirm == true) {
|
if (confirm == true) {
|
||||||
|
setState(() {
|
||||||
widget.entries.remove(item);
|
widget.entries.remove(item);
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
width: thumbnailSize,
|
width: thumbnailSize,
|
||||||
|
|
|
@ -134,11 +134,13 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openAction(BuildContext context) async {
|
Future<void> openAction(BuildContext context) async {
|
||||||
|
const editStatus = 'Edit Post';
|
||||||
const goToPost = 'Go to Post';
|
const goToPost = 'Go to Post';
|
||||||
const copyUrl = 'Copy URL';
|
const copyUrl = 'Copy URL';
|
||||||
const openExternal = 'Open In Browser';
|
const openExternal = 'Open In Browser';
|
||||||
final options = [
|
final options = [
|
||||||
if (widget.showOpenControl && !widget.openRemote) goToPost,
|
if (widget.showOpenControl && !widget.openRemote) goToPost,
|
||||||
|
if (widget.isMine && !widget.entry.youReshared) editStatus,
|
||||||
openExternal,
|
openExternal,
|
||||||
copyUrl,
|
copyUrl,
|
||||||
'Cancel'
|
'Cancel'
|
||||||
|
@ -154,6 +156,13 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
|
||||||
case goToPost:
|
case goToPost:
|
||||||
context.push('/post/view/${widget.entry.id}/${widget.entry.id}');
|
context.push('/post/view/${widget.entry.id}/${widget.entry.id}');
|
||||||
break;
|
break;
|
||||||
|
case editStatus:
|
||||||
|
if (widget.entry.parentId.isEmpty) {
|
||||||
|
context.push('/post/edit/${widget.entry.id}');
|
||||||
|
} else {
|
||||||
|
context.push('/comment/edit/${widget.entry.id}');
|
||||||
|
}
|
||||||
|
break;
|
||||||
case openExternal:
|
case openExternal:
|
||||||
await openUrlStringInSystembrowser(
|
await openUrlStringInSystembrowser(
|
||||||
context,
|
context,
|
||||||
|
|
|
@ -7,12 +7,15 @@ import 'data/memory/memory_groups_repo.dart';
|
||||||
import 'data/objectbox/objectbox_cache.dart';
|
import 'data/objectbox/objectbox_cache.dart';
|
||||||
import 'data/objectbox/objectbox_connections_repo.dart';
|
import 'data/objectbox/objectbox_connections_repo.dart';
|
||||||
import 'data/objectbox/objectbox_hashtag_repo.dart';
|
import 'data/objectbox/objectbox_hashtag_repo.dart';
|
||||||
|
import 'friendica_client/friendica_client.dart';
|
||||||
import 'globals.dart';
|
import 'globals.dart';
|
||||||
import 'models/auth/profile.dart';
|
import 'models/auth/profile.dart';
|
||||||
|
import 'models/instance_info.dart';
|
||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
import 'services/connections_manager.dart';
|
import 'services/connections_manager.dart';
|
||||||
import 'services/direct_message_service.dart';
|
import 'services/direct_message_service.dart';
|
||||||
import 'services/entry_manager_service.dart';
|
import 'services/entry_manager_service.dart';
|
||||||
|
import 'services/feature_version_checker.dart';
|
||||||
import 'services/gallery_service.dart';
|
import 'services/gallery_service.dart';
|
||||||
import 'services/hashtag_service.dart';
|
import 'services/hashtag_service.dart';
|
||||||
import 'services/interactions_manager.dart';
|
import 'services/interactions_manager.dart';
|
||||||
|
@ -32,6 +35,8 @@ Future<void> dependencyInjectionInitialization() async {
|
||||||
getIt.registerSingleton<IHashtagRepo>(ObjectBoxHashtagRepo());
|
getIt.registerSingleton<IHashtagRepo>(ObjectBoxHashtagRepo());
|
||||||
getIt.registerSingleton<HashtagService>(HashtagService());
|
getIt.registerSingleton<HashtagService>(HashtagService());
|
||||||
getIt.registerSingleton<NetworkStatusService>(NetworkStatusService());
|
getIt.registerSingleton<NetworkStatusService>(NetworkStatusService());
|
||||||
|
getIt.registerSingleton<FriendicaVersionChecker>(
|
||||||
|
const FriendicaVersionChecker());
|
||||||
|
|
||||||
final settingsService = SettingsService();
|
final settingsService = SettingsService();
|
||||||
await settingsService.initialize();
|
await settingsService.initialize();
|
||||||
|
@ -40,6 +45,9 @@ Future<void> dependencyInjectionInitialization() async {
|
||||||
getIt.registerSingleton<ActiveProfileSelector<IConnectionsRepo>>(
|
getIt.registerSingleton<ActiveProfileSelector<IConnectionsRepo>>(
|
||||||
ActiveProfileSelector(null));
|
ActiveProfileSelector(null));
|
||||||
|
|
||||||
|
getIt.registerSingleton<ActiveProfileSelector<InstanceInfo>>(
|
||||||
|
ActiveProfileSelector(null));
|
||||||
|
|
||||||
final secretsService = SecretsService();
|
final secretsService = SecretsService();
|
||||||
final serviceInit = await secretsService.initialize();
|
final serviceInit = await secretsService.initialize();
|
||||||
|
|
||||||
|
@ -93,4 +101,13 @@ Future<void> updateProfileDependencyInjectors(Profile profile) async {
|
||||||
getIt<ActiveProfileSelector<IConnectionsRepo>>();
|
getIt<ActiveProfileSelector<IConnectionsRepo>>();
|
||||||
connectionReposSelector.injectEntry(
|
connectionReposSelector.injectEntry(
|
||||||
profile, ObjectBoxConnectionsRepo(objectBox));
|
profile, ObjectBoxConnectionsRepo(objectBox));
|
||||||
|
|
||||||
|
final instanceSelector = getIt<ActiveProfileSelector<InstanceInfo>>();
|
||||||
|
final instanceInfo = await InstanceDataClient(profile).getInstanceData();
|
||||||
|
instanceInfo.andThenSuccess(
|
||||||
|
(instanceInfo) => instanceSelector.injectEntry(
|
||||||
|
profile,
|
||||||
|
instanceInfo,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,15 +16,16 @@ import '../models/exec_error.dart';
|
||||||
import '../models/gallery_data.dart';
|
import '../models/gallery_data.dart';
|
||||||
import '../models/group_data.dart';
|
import '../models/group_data.dart';
|
||||||
import '../models/image_entry.dart';
|
import '../models/image_entry.dart';
|
||||||
|
import '../models/instance_info.dart';
|
||||||
import '../models/media_attachment_uploads/image_types_enum.dart';
|
import '../models/media_attachment_uploads/image_types_enum.dart';
|
||||||
import '../models/timeline_entry.dart';
|
import '../models/timeline_entry.dart';
|
||||||
import '../models/user_notification.dart';
|
import '../models/user_notification.dart';
|
||||||
import '../serializers/friendica/connection_friendica_extensions.dart';
|
|
||||||
import '../serializers/friendica/direct_message_friendica_extensions.dart';
|
import '../serializers/friendica/direct_message_friendica_extensions.dart';
|
||||||
import '../serializers/friendica/gallery_data_friendica_extensions.dart';
|
import '../serializers/friendica/gallery_data_friendica_extensions.dart';
|
||||||
import '../serializers/friendica/image_entry_friendica_extensions.dart';
|
import '../serializers/friendica/image_entry_friendica_extensions.dart';
|
||||||
import '../serializers/mastodon/connection_mastodon_extensions.dart';
|
import '../serializers/mastodon/connection_mastodon_extensions.dart';
|
||||||
import '../serializers/mastodon/group_data_mastodon_extensions.dart';
|
import '../serializers/mastodon/group_data_mastodon_extensions.dart';
|
||||||
|
import '../serializers/mastodon/instance_info_mastodon_extensions.dart';
|
||||||
import '../serializers/mastodon/notification_mastodon_extension.dart';
|
import '../serializers/mastodon/notification_mastodon_extension.dart';
|
||||||
import '../serializers/mastodon/timeline_entry_mastodon_extensions.dart';
|
import '../serializers/mastodon/timeline_entry_mastodon_extensions.dart';
|
||||||
import '../services/network_status_service.dart';
|
import '../services/network_status_service.dart';
|
||||||
|
@ -261,6 +262,41 @@ class InteractionsClient extends FriendicaClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class InstanceDataClient extends FriendicaClient {
|
||||||
|
static final _logger = Logger('$InteractionsClient');
|
||||||
|
|
||||||
|
InstanceDataClient(super.credentials);
|
||||||
|
|
||||||
|
FutureResult<InstanceInfo, ExecError> getInstanceData() async {
|
||||||
|
_logger.finest(() => 'Getting $serverName instance info');
|
||||||
|
final v2Result = await getInstanceDataV2();
|
||||||
|
if (v2Result.isSuccess) {
|
||||||
|
return v2Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getInstanceDataV1();
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureResult<InstanceInfo, ExecError> getInstanceDataV1() async {
|
||||||
|
_logger.finest(() => 'Getting $serverName instance info via V1 endpoint');
|
||||||
|
final url = Uri.parse('https://$serverName/api/v1/instance');
|
||||||
|
final result = await _getApiRequest(url);
|
||||||
|
return result.andThen((json) {
|
||||||
|
return fromInstanceV1Json(json);
|
||||||
|
}).execErrorCast();
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureResult<InstanceInfo, ExecError> getInstanceDataV2() async {
|
||||||
|
_logger.finest(() => 'Getting $serverName instance info via V2 endpoint');
|
||||||
|
final url = Uri.parse('https://$serverName/api/v2/instance');
|
||||||
|
final result = await _getApiRequest(url);
|
||||||
|
return result.andThen((response) {
|
||||||
|
final instanceInfoResult = fromInstanceV2Json(response);
|
||||||
|
return instanceInfoResult;
|
||||||
|
}).execErrorCast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class NotificationsClient extends FriendicaClient {
|
class NotificationsClient extends FriendicaClient {
|
||||||
static final _logger = Logger('$NotificationsClient');
|
static final _logger = Logger('$NotificationsClient');
|
||||||
|
|
||||||
|
@ -549,10 +585,16 @@ class ProfileClient extends FriendicaClient {
|
||||||
|
|
||||||
FutureResult<Connection, ExecError> getMyProfile() async {
|
FutureResult<Connection, ExecError> getMyProfile() async {
|
||||||
_logger.finest(() => 'Getting logged in user profile');
|
_logger.finest(() => 'Getting logged in user profile');
|
||||||
final request = Uri.parse('https://$serverName/api/friendica/profile/show');
|
final request =
|
||||||
return (await _getApiRequest(request)).mapValue((json) =>
|
Uri.parse('https://$serverName/api/v1/accounts/verify_credentials');
|
||||||
ConnectionFriendicaExtensions.fromJson(json['friendica_owner'])
|
return (await _getApiRequest(request))
|
||||||
.copy(status: ConnectionStatus.you, network: 'friendica'));
|
.mapValue((json) => ConnectionMastodonExtensions.fromJson(
|
||||||
|
json,
|
||||||
|
defaultServerName: serverName,
|
||||||
|
).copy(
|
||||||
|
status: ConnectionStatus.you,
|
||||||
|
network: 'friendica',
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -604,6 +646,9 @@ class StatusesClient extends FriendicaClient {
|
||||||
if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText,
|
if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText,
|
||||||
if (inReplyToId.isNotEmpty) 'in_reply_to_id': inReplyToId,
|
if (inReplyToId.isNotEmpty) 'in_reply_to_id': inReplyToId,
|
||||||
if (mediaIds.isNotEmpty) 'media_ids': mediaIds,
|
if (mediaIds.isNotEmpty) 'media_ids': mediaIds,
|
||||||
|
'friendica': {
|
||||||
|
'title': '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
final result = await _postUrl(url, body);
|
final result = await _postUrl(url, body);
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
|
@ -620,6 +665,37 @@ class StatusesClient extends FriendicaClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FutureResult<TimelineEntry, ExecError> editStatus({
|
||||||
|
required String id,
|
||||||
|
required String text,
|
||||||
|
String spoilerText = '',
|
||||||
|
List<String> mediaIds = const [],
|
||||||
|
}) async {
|
||||||
|
_logger.finest(() => 'Updating status $id');
|
||||||
|
final url = Uri.parse('https://$serverName/api/v1/statuses/$id');
|
||||||
|
final body = {
|
||||||
|
'status': text,
|
||||||
|
if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText,
|
||||||
|
if (mediaIds.isNotEmpty) 'media_ids': mediaIds,
|
||||||
|
'friendica': {
|
||||||
|
'title': '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
final result = await _putUrl(url, body);
|
||||||
|
if (result.isFailure) {
|
||||||
|
return result.errorCast();
|
||||||
|
}
|
||||||
|
|
||||||
|
final responseText = result.value;
|
||||||
|
|
||||||
|
return runCatching<TimelineEntry>(() {
|
||||||
|
final json = jsonDecode(responseText);
|
||||||
|
return Result.ok(TimelineEntryMastodonExtensions.fromJson(json));
|
||||||
|
}).mapError((error) {
|
||||||
|
return ExecError(type: ErrorType.parsingError, message: error.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
FutureResult<TimelineEntry, ExecError> resharePost(String id) async {
|
FutureResult<TimelineEntry, ExecError> resharePost(String id) async {
|
||||||
_logger.finest(() => 'Reshare post $id');
|
_logger.finest(() => 'Reshare post $id');
|
||||||
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/reblog');
|
final url = Uri.parse('https://$serverName/api/v1/statuses/$id/reblog');
|
||||||
|
@ -806,6 +882,31 @@ abstract class FriendicaClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FutureResult<String, ExecError> _putUrl(
|
||||||
|
Uri url, Map<String, dynamic> body) async {
|
||||||
|
_logger.finer('PUT: $url \n Body: $body');
|
||||||
|
try {
|
||||||
|
final response = await http.put(
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Authorization': _profile.credentials.authHeaderValue,
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8'
|
||||||
|
},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
return Result.error(ExecError(
|
||||||
|
type: ErrorType.authentication,
|
||||||
|
message: '${response.statusCode}: ${response.reasonPhrase}'));
|
||||||
|
}
|
||||||
|
return Result.ok(utf8.decode(response.bodyBytes));
|
||||||
|
} catch (e) {
|
||||||
|
return Result.error(
|
||||||
|
ExecError(type: ErrorType.localError, message: e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FutureResult<String, ExecError> _deleteUrl(
|
FutureResult<String, ExecError> _deleteUrl(
|
||||||
Uri url, Map<String, dynamic> body) async {
|
Uri url, Map<String, dynamic> body) async {
|
||||||
_logger.finer('DELETE: $url');
|
_logger.finer('DELETE: $url');
|
||||||
|
|
192
lib/models/friendica_version.dart
Normal file
192
lib/models/friendica_version.dart
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
//Unknown
|
||||||
|
final FriendicaVersion unknown = FriendicaVersion(DateTime(1970, 01));
|
||||||
|
|
||||||
|
class FriendicaVersion implements Comparable<FriendicaVersion> {
|
||||||
|
final DateTime releaseDate;
|
||||||
|
final String extra;
|
||||||
|
|
||||||
|
FriendicaVersion(this.releaseDate, {this.extra = ''});
|
||||||
|
|
||||||
|
factory FriendicaVersion.fromMastodonVersionString(String? text) {
|
||||||
|
if (text == null) {
|
||||||
|
return unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
final elements = text.split(RegExp('\\s+'));
|
||||||
|
final versionString = elements.last.substring(0, elements.last.length - 1);
|
||||||
|
return FriendicaVersion.fromVersionString(versionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory FriendicaVersion.fromVersionString(String? text) {
|
||||||
|
if (text == null) {
|
||||||
|
return unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
final elements = text.trim().split('.');
|
||||||
|
if (elements.length < 2) {
|
||||||
|
return unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
final year = int.tryParse(elements[0]);
|
||||||
|
if (year == null) {
|
||||||
|
return unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year < 2018) {
|
||||||
|
return unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? month = int.tryParse(elements[1]);
|
||||||
|
String extra = '';
|
||||||
|
if (month == null) {
|
||||||
|
final secondaryFields = elements[1].split(RegExp('\\D+'));
|
||||||
|
month = int.tryParse(secondaryFields.first) ?? 1;
|
||||||
|
extra = secondaryFields.sublist(1).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
return FriendicaVersion(DateTime(year, month), extra: extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator >(FriendicaVersion other) {
|
||||||
|
return compareTo(other) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator >=(FriendicaVersion other) {
|
||||||
|
return compareTo(other) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator <(FriendicaVersion other) {
|
||||||
|
return compareTo(other) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator <=(FriendicaVersion other) {
|
||||||
|
return compareTo(other) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int compareTo(FriendicaVersion other) {
|
||||||
|
if (releaseDate == other.releaseDate) {
|
||||||
|
final subVersion = int.tryParse(extra);
|
||||||
|
final otherSubVersion = int.tryParse(other.extra);
|
||||||
|
if (subVersion == null && otherSubVersion == null) {
|
||||||
|
return extra.compareTo(other.extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subVersion == null && otherSubVersion != null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subVersion != null && otherSubVersion == null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return subVersion!.compareTo(otherSubVersion!);
|
||||||
|
}
|
||||||
|
return releaseDate.compareTo(other.releaseDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is FriendicaVersion &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
releaseDate == other.releaseDate &&
|
||||||
|
extra == other.extra;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => releaseDate.hashCode ^ extra.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'FriendicaVersion{releaseDate: $releaseDate, extra: $extra}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String toVersionString() {
|
||||||
|
final monthString = releaseDate.month.toString().padLeft(2, '0');
|
||||||
|
final extraString = extra.isEmpty ? '' : '-$extra';
|
||||||
|
return '${releaseDate.year}.$monthString$extraString';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Versions
|
||||||
|
// 2018 Versions
|
||||||
|
final FriendicaVersion v2018_05 = FriendicaVersion(DateTime(2018, 05));
|
||||||
|
final FriendicaVersion v2018_09 = FriendicaVersion(DateTime(2018, 09));
|
||||||
|
|
||||||
|
// 2019 Versions
|
||||||
|
final FriendicaVersion v2019_01 = FriendicaVersion(DateTime(2019, 01));
|
||||||
|
final FriendicaVersion v2019_03 = FriendicaVersion(DateTime(2019, 03));
|
||||||
|
final FriendicaVersion v2019_04 = FriendicaVersion(DateTime(2019, 04));
|
||||||
|
final FriendicaVersion v2019_06 = FriendicaVersion(DateTime(2019, 06));
|
||||||
|
final FriendicaVersion v2019_09 = FriendicaVersion(DateTime(2019, 09));
|
||||||
|
final FriendicaVersion v2019_12 = FriendicaVersion(DateTime(2019, 12));
|
||||||
|
|
||||||
|
// 2020 Versions
|
||||||
|
final FriendicaVersion v2020_03 = FriendicaVersion(DateTime(2020, 03));
|
||||||
|
final FriendicaVersion v2020_07 = FriendicaVersion(DateTime(2020, 07));
|
||||||
|
final FriendicaVersion v2020_07_01 = FriendicaVersion(
|
||||||
|
DateTime(2020, 07),
|
||||||
|
extra: '1',
|
||||||
|
);
|
||||||
|
final FriendicaVersion v2020_09 = FriendicaVersion(DateTime(2020, 09));
|
||||||
|
final FriendicaVersion v2020_09_01 = FriendicaVersion(
|
||||||
|
DateTime(2020, 09),
|
||||||
|
extra: '1',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2021 Versions
|
||||||
|
final FriendicaVersion v2021_01 = FriendicaVersion(DateTime(2021, 01));
|
||||||
|
final FriendicaVersion v2021_04 = FriendicaVersion(DateTime(2021, 04));
|
||||||
|
final FriendicaVersion v2021_07 = FriendicaVersion(DateTime(2021, 07));
|
||||||
|
final FriendicaVersion v2021_09 = FriendicaVersion(DateTime(2021, 09));
|
||||||
|
|
||||||
|
// 2022 Versions
|
||||||
|
final FriendicaVersion v2022_02 = FriendicaVersion(DateTime(2022, 02));
|
||||||
|
final FriendicaVersion v2022_03 = FriendicaVersion(DateTime(2022, 03));
|
||||||
|
final FriendicaVersion v2022_06 = FriendicaVersion(DateTime(2022, 06));
|
||||||
|
final FriendicaVersion v2022_10 = FriendicaVersion(DateTime(2022, 10));
|
||||||
|
final FriendicaVersion v2022_12 = FriendicaVersion(DateTime(2022, 12));
|
||||||
|
|
||||||
|
// 2023 Versions
|
||||||
|
final FriendicaVersion v2023_01 = FriendicaVersion(DateTime(2023, 01));
|
||||||
|
final FriendicaVersion v2023_03 = FriendicaVersion(DateTime(2023, 03));
|
||||||
|
|
||||||
|
final knownFriendicaVersions = [
|
||||||
|
// 2018 Versions
|
||||||
|
v2018_05,
|
||||||
|
v2018_05,
|
||||||
|
v2018_09,
|
||||||
|
|
||||||
|
// 2019 Versions
|
||||||
|
v2019_01,
|
||||||
|
v2019_03,
|
||||||
|
v2019_04,
|
||||||
|
v2019_06,
|
||||||
|
v2019_09,
|
||||||
|
v2019_12,
|
||||||
|
|
||||||
|
// 2020 Versions
|
||||||
|
v2020_03,
|
||||||
|
v2020_07_01,
|
||||||
|
v2020_09,
|
||||||
|
v2020_09_01,
|
||||||
|
|
||||||
|
// 2021 Versions
|
||||||
|
v2021_01,
|
||||||
|
v2021_04,
|
||||||
|
v2021_07,
|
||||||
|
v2021_09,
|
||||||
|
|
||||||
|
// 2022 Versions
|
||||||
|
v2022_02,
|
||||||
|
v2022_03,
|
||||||
|
v2022_06,
|
||||||
|
v2022_10,
|
||||||
|
v2022_12,
|
||||||
|
|
||||||
|
// 2023 Versions
|
||||||
|
v2023_01,
|
||||||
|
v2023_03,
|
||||||
|
];
|
||||||
|
|
||||||
|
FriendicaVersion latestVersion() => knownFriendicaVersions.last;
|
20
lib/models/instance_info.dart
Normal file
20
lib/models/instance_info.dart
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import 'friendica_version.dart';
|
||||||
|
|
||||||
|
class InstanceInfo {
|
||||||
|
final int maxStatusCharacters;
|
||||||
|
final FriendicaVersion friendicaVersion;
|
||||||
|
final String versionString;
|
||||||
|
final int maxImageBytes;
|
||||||
|
|
||||||
|
InstanceInfo({
|
||||||
|
required this.maxStatusCharacters,
|
||||||
|
required this.friendicaVersion,
|
||||||
|
required this.versionString,
|
||||||
|
required this.maxImageBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'InstanceInfo{maxStatusCharacters: $maxStatusCharacters, version: $versionString, maxImageBytes: $maxImageBytes}';
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:relatica/models/image_entry.dart';
|
||||||
|
|
||||||
import '../globals.dart';
|
import '../globals.dart';
|
||||||
import 'attachment_media_type_enum.dart';
|
import 'attachment_media_type_enum.dart';
|
||||||
|
@ -7,6 +8,8 @@ class MediaAttachment {
|
||||||
static final _graphicsExtensions = ['jpg', 'png', 'gif', 'tif'];
|
static final _graphicsExtensions = ['jpg', 'png', 'gif', 'tif'];
|
||||||
static final _movieExtensions = ['avi', 'mp4', 'mpg', 'wmv'];
|
static final _movieExtensions = ['avi', 'mp4', 'mpg', 'wmv'];
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
final Uri uri;
|
final Uri uri;
|
||||||
|
|
||||||
final int creationTimestamp;
|
final int creationTimestamp;
|
||||||
|
@ -24,7 +27,8 @@ class MediaAttachment {
|
||||||
final String description;
|
final String description;
|
||||||
|
|
||||||
MediaAttachment(
|
MediaAttachment(
|
||||||
{required this.uri,
|
{required this.id,
|
||||||
|
required this.uri,
|
||||||
required this.creationTimestamp,
|
required this.creationTimestamp,
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
required this.thumbnailUri,
|
required this.thumbnailUri,
|
||||||
|
@ -34,7 +38,8 @@ class MediaAttachment {
|
||||||
required this.description});
|
required this.description});
|
||||||
|
|
||||||
MediaAttachment.randomBuilt()
|
MediaAttachment.randomBuilt()
|
||||||
: uri = Uri.parse('http://localhost/${randomId()}'),
|
: id = randomId(),
|
||||||
|
uri = Uri.parse('http://localhost/${randomId()}'),
|
||||||
creationTimestamp = DateTime.now().millisecondsSinceEpoch,
|
creationTimestamp = DateTime.now().millisecondsSinceEpoch,
|
||||||
fullFileUri = Uri.parse(''),
|
fullFileUri = Uri.parse(''),
|
||||||
title = 'Random title ${randomId()}',
|
title = 'Random title ${randomId()}',
|
||||||
|
@ -44,7 +49,8 @@ class MediaAttachment {
|
||||||
metadata = {'value1': randomId(), 'value2': randomId()};
|
metadata = {'value1': randomId(), 'value2': randomId()};
|
||||||
|
|
||||||
MediaAttachment.blank()
|
MediaAttachment.blank()
|
||||||
: uri = Uri(),
|
: id = '',
|
||||||
|
uri = Uri(),
|
||||||
creationTimestamp = 0,
|
creationTimestamp = 0,
|
||||||
thumbnailUri = Uri(),
|
thumbnailUri = Uri(),
|
||||||
explicitType = AttachmentMediaType.unknown,
|
explicitType = AttachmentMediaType.unknown,
|
||||||
|
@ -55,6 +61,7 @@ class MediaAttachment {
|
||||||
|
|
||||||
factory MediaAttachment.fromMastodonJson(Map<String, dynamic> json) =>
|
factory MediaAttachment.fromMastodonJson(Map<String, dynamic> json) =>
|
||||||
MediaAttachment(
|
MediaAttachment(
|
||||||
|
id: json['id'] ?? '',
|
||||||
uri: Uri.parse(json['url'] ?? 'http://localhost'),
|
uri: Uri.parse(json['url'] ?? 'http://localhost'),
|
||||||
creationTimestamp: 0,
|
creationTimestamp: 0,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
|
@ -66,7 +73,20 @@ class MediaAttachment {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'FriendicaMediaAttachment{uri: $uri, creationTimestamp: $creationTimestamp, type: $explicitType, metadata: $metadata, title: $title, description: $description}';
|
return 'FriendicaMediaAttachment{id: $id, uri: $uri, creationTimestamp: $creationTimestamp, type: $explicitType, metadata: $metadata, title: $title, description: $description}';
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageEntry toImageEntry() {
|
||||||
|
return ImageEntry(
|
||||||
|
id: id,
|
||||||
|
album: '',
|
||||||
|
filename: '',
|
||||||
|
description: description,
|
||||||
|
thumbnailUrl: thumbnailUri.toString(),
|
||||||
|
created: DateTime.fromMillisecondsSinceEpoch(creationTimestamp),
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
scales: []);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
|
|
|
@ -164,12 +164,16 @@ final appRouter = GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'new',
|
path: 'new',
|
||||||
builder: (context, state) => EditorScreen(),
|
builder: (context, state) => EditorScreen(
|
||||||
|
forEditing: false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'edit/:id',
|
path: 'edit/:id',
|
||||||
builder: (context, state) =>
|
builder: (context, state) => EditorScreen(
|
||||||
EditorScreen(id: state.params['id'] ?? 'Not Found'),
|
id: state.params['id'] ?? 'Not Found',
|
||||||
|
forEditing: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'view/:id/:goto_id',
|
path: 'view/:id/:goto_id',
|
||||||
|
@ -193,12 +197,15 @@ final appRouter = GoRouter(
|
||||||
path: 'new',
|
path: 'new',
|
||||||
builder: (context, state) => EditorScreen(
|
builder: (context, state) => EditorScreen(
|
||||||
parentId: state.queryParams['parent_id'] ?? '',
|
parentId: state.queryParams['parent_id'] ?? '',
|
||||||
|
forEditing: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'edit/:id',
|
path: 'edit/:id',
|
||||||
builder: (context, state) =>
|
builder: (context, state) => EditorScreen(
|
||||||
EditorScreen(id: state.params['id'] ?? 'Not Found'),
|
id: state.params['id'] ?? 'Not Found',
|
||||||
|
forEditing: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
|
@ -13,18 +13,23 @@ import '../controls/entry_media_attachments/media_uploads_control.dart';
|
||||||
import '../controls/padding.dart';
|
import '../controls/padding.dart';
|
||||||
import '../controls/standard_appbar.dart';
|
import '../controls/standard_appbar.dart';
|
||||||
import '../controls/timeline/status_header_control.dart';
|
import '../controls/timeline/status_header_control.dart';
|
||||||
|
import '../globals.dart';
|
||||||
import '../models/image_entry.dart';
|
import '../models/image_entry.dart';
|
||||||
import '../models/media_attachment_uploads/new_entry_media_items.dart';
|
import '../models/media_attachment_uploads/new_entry_media_items.dart';
|
||||||
import '../models/timeline_entry.dart';
|
import '../models/timeline_entry.dart';
|
||||||
|
import '../services/feature_version_checker.dart';
|
||||||
import '../services/timeline_manager.dart';
|
import '../services/timeline_manager.dart';
|
||||||
import '../utils/active_profile_selector.dart';
|
import '../utils/active_profile_selector.dart';
|
||||||
|
import '../utils/html_to_edit_text_helper.dart';
|
||||||
import '../utils/snackbar_builder.dart';
|
import '../utils/snackbar_builder.dart';
|
||||||
|
|
||||||
class EditorScreen extends StatefulWidget {
|
class EditorScreen extends StatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
final String parentId;
|
final String parentId;
|
||||||
|
final bool forEditing;
|
||||||
|
|
||||||
const EditorScreen({super.key, this.id = '', this.parentId = ''});
|
const EditorScreen(
|
||||||
|
{super.key, this.id = '', this.parentId = '', required this.forEditing});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<EditorScreen> createState() => _EditorScreenState();
|
State<EditorScreen> createState() => _EditorScreenState();
|
||||||
|
@ -49,21 +54,53 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
String get localEntryId =>
|
String get localEntryId =>
|
||||||
widget.id.isNotEmpty ? widget.id : localEntryTemporaryId;
|
widget.id.isNotEmpty ? widget.id : localEntryTemporaryId;
|
||||||
|
|
||||||
|
bool loaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
if (!isComment) {
|
if (isComment) {
|
||||||
return;
|
final manager = context
|
||||||
|
.read<ActiveProfileSelector<TimelineManager>>()
|
||||||
|
.activeEntry
|
||||||
|
.value;
|
||||||
|
manager.getEntryById(widget.parentId).match(onSuccess: (entry) {
|
||||||
|
spoilerController.text = entry.spoilerText;
|
||||||
|
parentEntry = entry;
|
||||||
|
}, onError: (error) {
|
||||||
|
_logger.finest('Error trying to get parent entry: $error');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final manager = context
|
if (widget.forEditing) {
|
||||||
.read<ActiveProfileSelector<TimelineManager>>()
|
restoreStatusData();
|
||||||
|
} else {
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void restoreStatusData() async {
|
||||||
|
_logger.fine('Attempting to load status for editing');
|
||||||
|
loaded = false;
|
||||||
|
final result = await getIt<ActiveProfileSelector<TimelineManager>>()
|
||||||
.activeEntry
|
.activeEntry
|
||||||
.value;
|
.andThenAsync((manager) async => await manager.getEntryById(widget.id));
|
||||||
manager.getEntryById(widget.parentId).match(onSuccess: (entry) {
|
result.match(onSuccess: (entry) {
|
||||||
|
_logger.fine('Loading status ${widget.id} information into fields');
|
||||||
|
contentController.text = toEditTextField(entry.body);
|
||||||
spoilerController.text = entry.spoilerText;
|
spoilerController.text = entry.spoilerText;
|
||||||
parentEntry = entry;
|
existingMediaItems
|
||||||
|
.addAll(entry.mediaAttachments.map((e) => e.toImageEntry()));
|
||||||
|
setState(() {
|
||||||
|
loaded = true;
|
||||||
|
});
|
||||||
}, onError: (error) {
|
}, onError: (error) {
|
||||||
_logger.finest('Error trying to get parent entry: $error');
|
if (context.mounted) {
|
||||||
|
buildSnackbar(
|
||||||
|
context,
|
||||||
|
'Error getting post for editing: $error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_logger.severe('Error getting post for editing: $error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,6 +136,38 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> editStatus(BuildContext context, TimelineManager manager) async {
|
||||||
|
if (contentController.text.isEmpty) {
|
||||||
|
buildSnackbar(context, "Can't submit an empty post/comment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await manager.editStatus(
|
||||||
|
widget.id,
|
||||||
|
contentController.text,
|
||||||
|
spoilerText: spoilerController.text,
|
||||||
|
inReplyToId: widget.parentId,
|
||||||
|
newMediaItems: newMediaItems,
|
||||||
|
existingMediaItems: existingMediaItems,
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
isSubmitting = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.isFailure) {
|
||||||
|
buildSnackbar(context, 'Error Updating $statusType: ${result.error}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && context.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_logger.finest('Build editor $isComment $parentEntry');
|
_logger.finest('Build editor $isComment $parentEntry');
|
||||||
|
@ -107,73 +176,76 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
.activeEntry
|
.activeEntry
|
||||||
.value;
|
.value;
|
||||||
|
|
||||||
final mainBody = Padding(
|
final vc = getIt<FriendicaVersionChecker>();
|
||||||
padding: const EdgeInsets.all(8.0),
|
final canEdit = vc.canUseFeature(RelaticaFeatures.statusEditing);
|
||||||
child: Container(
|
|
||||||
child: SingleChildScrollView(
|
late final body;
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (widget.forEditing && !canEdit) {
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
body = Center(
|
||||||
children: [
|
child: Column(
|
||||||
if (isComment && parentEntry != null)
|
children: [
|
||||||
buildCommentPreview(context, parentEntry!),
|
Text(vc.versionErrorString(RelaticaFeatures.statusEditing)),
|
||||||
TextFormField(
|
const VerticalPadding(),
|
||||||
readOnly: isSubmitting,
|
ElevatedButton(
|
||||||
enabled: !isSubmitting,
|
onPressed: () => context.pop(), child: const Text('Back')),
|
||||||
controller: spoilerController,
|
],
|
||||||
decoration: InputDecoration(
|
),
|
||||||
labelText: '$statusType Spoiler Text (optional)',
|
);
|
||||||
border: OutlineInputBorder(
|
} else {
|
||||||
borderSide: BorderSide(
|
final mainBody = Padding(
|
||||||
color: Theme.of(context).backgroundColor,
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Container(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (isComment && parentEntry != null)
|
||||||
|
buildCommentPreview(context, parentEntry!),
|
||||||
|
TextFormField(
|
||||||
|
readOnly: isSubmitting,
|
||||||
|
enabled: !isSubmitting,
|
||||||
|
controller: spoilerController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '$statusType Spoiler Text (optional)',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: Theme.of(context).backgroundColor,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(5.0),
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(5.0),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const VerticalPadding(),
|
||||||
const VerticalPadding(),
|
buildContentField(context),
|
||||||
buildContentField(context),
|
const VerticalPadding(),
|
||||||
const VerticalPadding(),
|
GallerySelectorControl(entries: existingMediaItems),
|
||||||
GallerySelectorControl(entries: existingMediaItems),
|
const VerticalPadding(),
|
||||||
const VerticalPadding(),
|
MediaUploadsControl(
|
||||||
MediaUploadsControl(
|
entryMediaItems: newMediaItems,
|
||||||
entryMediaItems: newMediaItems,
|
),
|
||||||
),
|
buildButtonBar(context, manager),
|
||||||
buildButtonBar(context, manager),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final submittingBody = Stack(
|
|
||||||
children: [
|
|
||||||
mainBody,
|
|
||||||
Card(
|
|
||||||
color: Theme.of(context).canvasColor.withOpacity(0.8),
|
|
||||||
child: SizedBox(
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
height: MediaQuery.of(context).size.height,
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const CircularProgressIndicator(),
|
|
||||||
const VerticalPadding(),
|
|
||||||
Text('Submitting New $statusType'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
);
|
|
||||||
|
if (widget.forEditing && !loaded) {
|
||||||
|
body = buildBusyBody(context, mainBody, 'Loading status');
|
||||||
|
} else if (isSubmitting) {
|
||||||
|
body = buildBusyBody(context, mainBody, 'Submitting $statusType');
|
||||||
|
} else {
|
||||||
|
body = mainBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: StandardAppBar.build(
|
appBar: StandardAppBar.build(
|
||||||
context, widget.id.isEmpty ? 'New $statusType' : 'Edit $statusType',
|
context, widget.id.isEmpty ? 'New $statusType' : 'Edit $statusType',
|
||||||
withDrawer: true),
|
withDrawer: true),
|
||||||
body: isSubmitting ? submittingBody : mainBody,
|
body: body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -270,10 +342,17 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
if (!widget.forEditing)
|
||||||
onPressed: isSubmitting ? null : () => createStatus(context, manager),
|
ElevatedButton(
|
||||||
child: const Text('Submit'),
|
onPressed:
|
||||||
),
|
isSubmitting ? null : () => createStatus(context, manager),
|
||||||
|
child: const Text('Submit'),
|
||||||
|
),
|
||||||
|
if (widget.forEditing)
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isSubmitting ? null : () => editStatus(context, manager),
|
||||||
|
child: const Text('Edit'),
|
||||||
|
),
|
||||||
const HorizontalPadding(),
|
const HorizontalPadding(),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: isSubmitting
|
onPressed: isSubmitting
|
||||||
|
@ -286,4 +365,29 @@ class _EditorScreenState extends State<EditorScreen> {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget buildBusyBody(BuildContext context, Widget mainBody, String status) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
mainBody,
|
||||||
|
Card(
|
||||||
|
color: Theme.of(context).canvasColor.withOpacity(0.8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
height: MediaQuery.of(context).size.height,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const VerticalPadding(),
|
||||||
|
Text(status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ extension ImageEntryFriendicaExtension on ImageEntry {
|
||||||
final thumbUri = Uri.parse(thumbnailUrl);
|
final thumbUri = Uri.parse(thumbnailUrl);
|
||||||
final fullFileUri = scales.first.link;
|
final fullFileUri = scales.first.link;
|
||||||
return MediaAttachment(
|
return MediaAttachment(
|
||||||
|
id: id,
|
||||||
uri: fullFileUri,
|
uri: fullFileUri,
|
||||||
fullFileUri: fullFileUri,
|
fullFileUri: fullFileUri,
|
||||||
creationTimestamp: created.millisecondsSinceEpoch,
|
creationTimestamp: created.millisecondsSinceEpoch,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import '../../models/media_attachment.dart';
|
||||||
|
|
||||||
extension MediaAttachmentFriendicaExtensions on MediaAttachment {
|
extension MediaAttachmentFriendicaExtensions on MediaAttachment {
|
||||||
static MediaAttachment fromJson(Map<String, dynamic> json) {
|
static MediaAttachment fromJson(Map<String, dynamic> json) {
|
||||||
|
final id = json['id'];
|
||||||
final uri = Uri.parse(json['url']);
|
final uri = Uri.parse(json['url']);
|
||||||
const creationTimestamp = 0;
|
const creationTimestamp = 0;
|
||||||
final metadata = (json['metadata'] as Map<String, dynamic>? ?? {})
|
final metadata = (json['metadata'] as Map<String, dynamic>? ?? {})
|
||||||
|
@ -17,6 +18,7 @@ extension MediaAttachmentFriendicaExtensions on MediaAttachment {
|
||||||
const description = '';
|
const description = '';
|
||||||
|
|
||||||
return MediaAttachment(
|
return MediaAttachment(
|
||||||
|
id: id,
|
||||||
uri: uri,
|
uri: uri,
|
||||||
fullFileUri: uri,
|
fullFileUri: uri,
|
||||||
creationTimestamp: creationTimestamp,
|
creationTimestamp: creationTimestamp,
|
||||||
|
|
|
@ -3,7 +3,8 @@ import '../../models/connection.dart';
|
||||||
import '../../services/auth_service.dart';
|
import '../../services/auth_service.dart';
|
||||||
|
|
||||||
extension ConnectionMastodonExtensions on Connection {
|
extension ConnectionMastodonExtensions on Connection {
|
||||||
static Connection fromJson(Map<String, dynamic> json) {
|
static Connection fromJson(Map<String, dynamic> json,
|
||||||
|
{String defaultServerName = ''}) {
|
||||||
final name = json['display_name'] ?? '';
|
final name = json['display_name'] ?? '';
|
||||||
final id = json['id']?.toString() ?? '';
|
final id = json['id']?.toString() ?? '';
|
||||||
final profileUrl = Uri.parse(json['url'] ?? '');
|
final profileUrl = Uri.parse(json['url'] ?? '');
|
||||||
|
@ -20,7 +21,9 @@ extension ConnectionMastodonExtensions on Connection {
|
||||||
if (handleFromJson.contains('@')) {
|
if (handleFromJson.contains('@')) {
|
||||||
handle = handleFromJson;
|
handle = handleFromJson;
|
||||||
} else {
|
} else {
|
||||||
final server = getIt<AccountsService>().currentProfile.serverName;
|
final server = defaultServerName.isNotEmpty
|
||||||
|
? defaultServerName
|
||||||
|
: getIt<AccountsService>().currentProfile.serverName;
|
||||||
handle = '$handleFromJson@$server';
|
handle = '$handleFromJson@$server';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import 'package:result_monad/result_monad.dart';
|
||||||
|
|
||||||
|
import '../../models/exec_error.dart';
|
||||||
|
import '../../models/friendica_version.dart';
|
||||||
|
import '../../models/instance_info.dart';
|
||||||
|
|
||||||
|
const _defaultMaxImageBytes = 819200;
|
||||||
|
const _defaultMaxCharacters = 4000;
|
||||||
|
|
||||||
|
Result<InstanceInfo, ExecError> fromInstanceV1Json(Map<String, dynamic> json) {
|
||||||
|
return runCatching(() {
|
||||||
|
final maxStatusCharacters = json['max_toot_chars'] ?? _defaultMaxCharacters;
|
||||||
|
final versionString = json['version'];
|
||||||
|
return Result.ok(InstanceInfo(
|
||||||
|
friendicaVersion: FriendicaVersion.fromMastodonVersionString(
|
||||||
|
versionString,
|
||||||
|
),
|
||||||
|
maxStatusCharacters: maxStatusCharacters,
|
||||||
|
versionString: versionString,
|
||||||
|
maxImageBytes: _defaultMaxImageBytes,
|
||||||
|
));
|
||||||
|
}).execErrorCast();
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<InstanceInfo, ExecError> fromInstanceV2Json(Map<String, dynamic> json) {
|
||||||
|
return runCatching(() {
|
||||||
|
final maxStatusCharacters = json['configuration']?['statuses']
|
||||||
|
?['max_characters'] ??
|
||||||
|
_defaultMaxCharacters;
|
||||||
|
final maxImageBytes = json['configuration']?['media_attachments']
|
||||||
|
?['image_size_limit'] ??
|
||||||
|
_defaultMaxImageBytes;
|
||||||
|
final versionString =
|
||||||
|
json['friendica']?['version'] ?? 'Not a friendica server';
|
||||||
|
final version = FriendicaVersion.fromVersionString(versionString);
|
||||||
|
return Result.ok(InstanceInfo(
|
||||||
|
friendicaVersion: version,
|
||||||
|
maxStatusCharacters: maxStatusCharacters,
|
||||||
|
versionString: versionString,
|
||||||
|
maxImageBytes: maxImageBytes,
|
||||||
|
));
|
||||||
|
}).execErrorCast();
|
||||||
|
}
|
|
@ -166,6 +166,93 @@ class EntryManagerService extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FutureResult<bool, ExecError> editStatus(
|
||||||
|
String id,
|
||||||
|
String text, {
|
||||||
|
String spoilerText = '',
|
||||||
|
required NewEntryMediaItems mediaItems,
|
||||||
|
required List<ImageEntry> existingMediaItems,
|
||||||
|
}) async {
|
||||||
|
_logger.finest('Editing post: $text');
|
||||||
|
final mediaIds = existingMediaItems
|
||||||
|
.map((m) => m.scales.isEmpty ? m.id : m.scales.first.id)
|
||||||
|
.toList();
|
||||||
|
for (final item in mediaItems.attachments) {
|
||||||
|
if (item.isExistingServerItem) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String extension = p.extension(item.localFilePath);
|
||||||
|
late final String filename;
|
||||||
|
if (item.remoteFilename.isEmpty) {
|
||||||
|
filename = p.basename(item.localFilePath);
|
||||||
|
} else {
|
||||||
|
if (item.remoteFilename
|
||||||
|
.toLowerCase()
|
||||||
|
.endsWith(extension.toLowerCase())) {
|
||||||
|
filename = item.remoteFilename;
|
||||||
|
} else {
|
||||||
|
filename = "${item.remoteFilename}$extension";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final uploadResult =
|
||||||
|
await MediaUploadAttachmentHelper.getUploadableImageBytes(
|
||||||
|
item.localFilePath,
|
||||||
|
).andThenAsync(
|
||||||
|
(imageBytes) async =>
|
||||||
|
await RemoteFileClient(getIt<AccountsService>().currentProfile)
|
||||||
|
.uploadFileAsAttachment(
|
||||||
|
bytes: imageBytes,
|
||||||
|
album: mediaItems.albumName,
|
||||||
|
description: item.description,
|
||||||
|
fileName: filename,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (uploadResult.isSuccess) {
|
||||||
|
mediaIds.add(uploadResult.value.scales.first.id);
|
||||||
|
} else {
|
||||||
|
return Result.error(ExecError(
|
||||||
|
type: ErrorType.localError,
|
||||||
|
message: 'Error uploading image: ${uploadResult.error}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await StatusesClient(getIt<AccountsService>().currentProfile)
|
||||||
|
.editStatus(
|
||||||
|
id: id, text: text, spoilerText: spoilerText, mediaIds: mediaIds)
|
||||||
|
.andThenSuccessAsync((item) async {
|
||||||
|
await processNewItems(
|
||||||
|
[item], getIt<AccountsService>().currentProfile.username, null);
|
||||||
|
return item;
|
||||||
|
}).andThenSuccessAsync((item) async {
|
||||||
|
final inReplyToId = item.parentId;
|
||||||
|
if (inReplyToId.isNotEmpty) {
|
||||||
|
late final rootPostId;
|
||||||
|
if (_postNodes.containsKey(inReplyToId)) {
|
||||||
|
rootPostId = inReplyToId;
|
||||||
|
} else {
|
||||||
|
rootPostId = _parentPostIds[inReplyToId];
|
||||||
|
}
|
||||||
|
await refreshStatusChain(rootPostId);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.mapValue((status) {
|
||||||
|
_logger.finest('${status.id} status created');
|
||||||
|
return true;
|
||||||
|
}).mapError(
|
||||||
|
(error) {
|
||||||
|
_logger.finest('Error creating post: $error');
|
||||||
|
return ExecError(
|
||||||
|
type: ErrorType.localError,
|
||||||
|
message: error.toString(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
|
FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
|
||||||
TimelineIdentifiers type, int maxId, int sinceId) async {
|
TimelineIdentifiers type, int maxId, int sinceId) async {
|
||||||
_logger.fine(() => 'Updating timeline');
|
_logger.fine(() => 'Updating timeline');
|
||||||
|
|
48
lib/services/feature_version_checker.dart
Normal file
48
lib/services/feature_version_checker.dart
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:relatica/models/instance_info.dart';
|
||||||
|
import 'package:relatica/utils/active_profile_selector.dart';
|
||||||
|
|
||||||
|
import '../globals.dart';
|
||||||
|
import '../models/friendica_version.dart';
|
||||||
|
|
||||||
|
enum RelaticaFeatures {
|
||||||
|
statusEditing,
|
||||||
|
}
|
||||||
|
|
||||||
|
class FriendicaVersionChecker {
|
||||||
|
static final _logger = Logger('FriendicaVersionTest');
|
||||||
|
|
||||||
|
const FriendicaVersionChecker();
|
||||||
|
|
||||||
|
bool canUseFeature(RelaticaFeatures feature) {
|
||||||
|
final neededVersion = featureVersionRequirement[feature];
|
||||||
|
if (neededVersion == null) {
|
||||||
|
_logger.severe(
|
||||||
|
'Return false since no minimum version data in table for: $feature',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getIt<ActiveProfileSelector<InstanceInfo>>()
|
||||||
|
.activeEntry
|
||||||
|
.andThenSuccess((info) => info.friendicaVersion >= neededVersion)
|
||||||
|
.fold(
|
||||||
|
onSuccess: (versionMet) => versionMet,
|
||||||
|
onError: (error) {
|
||||||
|
_logger.severe(
|
||||||
|
'Unable to calculate needed version data so returning false: $error');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FriendicaVersion getVersionRequirement(RelaticaFeatures feature) =>
|
||||||
|
featureVersionRequirement[feature] ?? unknown;
|
||||||
|
|
||||||
|
String versionErrorString(RelaticaFeatures feature) =>
|
||||||
|
"This account's server doesn't meet the necessary minimum requirement: ${getVersionRequirement(feature).toVersionString()}";
|
||||||
|
|
||||||
|
static final featureVersionRequirement = <RelaticaFeatures, FriendicaVersion>{
|
||||||
|
RelaticaFeatures.statusEditing: v2023_03,
|
||||||
|
};
|
||||||
|
}
|
|
@ -84,6 +84,28 @@ class TimelineManager extends ChangeNotifier {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FutureResult<bool, ExecError> editStatus(
|
||||||
|
String id,
|
||||||
|
String text, {
|
||||||
|
String spoilerText = '',
|
||||||
|
String inReplyToId = '',
|
||||||
|
required NewEntryMediaItems newMediaItems,
|
||||||
|
required List<ImageEntry> existingMediaItems,
|
||||||
|
}) async {
|
||||||
|
final result = await entryManagerService.editStatus(
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
spoilerText: spoilerText,
|
||||||
|
mediaItems: newMediaItems,
|
||||||
|
existingMediaItems: existingMediaItems,
|
||||||
|
);
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_logger.finest('Notifying listeners of updated status');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Result<TimelineEntry, ExecError> getEntryById(String id) {
|
Result<TimelineEntry, ExecError> getEntryById(String id) {
|
||||||
_logger.finest('Getting entry for $id');
|
_logger.finest('Getting entry for $id');
|
||||||
return entryManagerService.getEntryById(id);
|
return entryManagerService.getEntryById(id);
|
||||||
|
|
115
lib/utils/html_to_edit_text_helper.dart
Normal file
115
lib/utils/html_to_edit_text_helper.dart
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
import 'package:html/dom.dart';
|
||||||
|
import 'package:html/parser.dart';
|
||||||
|
|
||||||
|
String toEditTextField(String htmlContentFragment) {
|
||||||
|
final dom = parseFragment(htmlContentFragment);
|
||||||
|
final segments = dom.nodes
|
||||||
|
.map((n) => n is Element ? n.elementToEditText() : n.nodeToEditText())
|
||||||
|
.toList();
|
||||||
|
return segments.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NodeTextConverter on Node {
|
||||||
|
String nodeToEditText() {
|
||||||
|
if (nodes.isEmpty) {
|
||||||
|
final stringWithQuotes = toString();
|
||||||
|
final start = stringWithQuotes.startsWith('"') ? 1 : 0;
|
||||||
|
final end = stringWithQuotes.endsWith('"')
|
||||||
|
? stringWithQuotes.length - 1
|
||||||
|
: stringWithQuotes.length;
|
||||||
|
return stringWithQuotes.substring(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
final convertedNodes = nodes
|
||||||
|
.map((n) => n is Element ? n.elementToEditText() : n.nodeToEditText())
|
||||||
|
.toList();
|
||||||
|
return convertedNodes.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ElementTextConverter on Element {
|
||||||
|
String elementToEditText({int depth = 0}) {
|
||||||
|
late final String innerText;
|
||||||
|
late final String startText;
|
||||||
|
late final String endText;
|
||||||
|
switch (localName) {
|
||||||
|
case 'a':
|
||||||
|
startText = '';
|
||||||
|
innerText = htmlLinkToString();
|
||||||
|
endText = '';
|
||||||
|
break;
|
||||||
|
case 'br':
|
||||||
|
startText = '';
|
||||||
|
innerText = '';
|
||||||
|
endText = '\n';
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
|
startText = '';
|
||||||
|
innerText = buildInnerText(depth);
|
||||||
|
endText = '\n';
|
||||||
|
break;
|
||||||
|
case 'em':
|
||||||
|
startText = '*';
|
||||||
|
innerText = buildInnerText(depth);
|
||||||
|
endText = '*';
|
||||||
|
break;
|
||||||
|
case 'strong':
|
||||||
|
startText = '**';
|
||||||
|
innerText = buildInnerText(depth);
|
||||||
|
endText = '**';
|
||||||
|
break;
|
||||||
|
case 'li':
|
||||||
|
startText = '\n${buildTabs(depth)}- ';
|
||||||
|
innerText = buildInnerText(depth);
|
||||||
|
endText = '';
|
||||||
|
break;
|
||||||
|
case 'ul':
|
||||||
|
startText = '';
|
||||||
|
innerText = buildInnerText(depth + 1);
|
||||||
|
endText = '';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
startText = '<$localName>';
|
||||||
|
innerText = buildInnerText(depth);
|
||||||
|
endText = '</$localName>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '$startText$innerText$endText';
|
||||||
|
}
|
||||||
|
|
||||||
|
String htmlLinkToString() {
|
||||||
|
final attrs = attributes['class'] ?? '';
|
||||||
|
if (attrs.contains('hashtag')) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.contains('mention')) {
|
||||||
|
final uri = Uri.parse(attributes['href'] ?? '');
|
||||||
|
final host = uri.host;
|
||||||
|
final username = text;
|
||||||
|
return '$username@$host';
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes['href'] ?? 'No link found';
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildInnerText(int depth) {
|
||||||
|
if (nodes.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
final convertedNodes = nodes
|
||||||
|
.map((n) => n is Element
|
||||||
|
? n.elementToEditText(depth: depth)
|
||||||
|
: n.nodeToEditText())
|
||||||
|
.toList();
|
||||||
|
return convertedNodes.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildTabs(int depth) => depth == 0
|
||||||
|
? ''
|
||||||
|
: List.generate(
|
||||||
|
depth,
|
||||||
|
(index) => ' ',
|
||||||
|
).join('');
|
||||||
|
}
|
224
test/friendica_version_test.dart
Normal file
224
test/friendica_version_test.dart
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:relatica/models/friendica_version.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Comparison tests', () {
|
||||||
|
test('Unknown less than all', () {
|
||||||
|
for (final v in knownFriendicaVersions) {
|
||||||
|
expect(v, greaterThan(unknown));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Newer correct', () {
|
||||||
|
expect(v2022_02, greaterThan(v2021_01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Older correct', () {
|
||||||
|
expect(v2022_02, lessThan(v2023_01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Older or equals', () {
|
||||||
|
expect(v2022_02, lessThanOrEqualTo(v2022_02));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Newer or equals', () {
|
||||||
|
expect(v2022_02, greaterThanOrEqualTo(v2022_02));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Equals with sub-version', () {
|
||||||
|
expect(v2020_07_01, greaterThanOrEqualTo(v2020_07_01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Newer sub-version', () {
|
||||||
|
expect(v2020_07_01, greaterThan(v2020_07));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Older non sub-version', () {
|
||||||
|
expect(v2020_07, lessThan(v2020_07_01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Newer sub-version for both', () {
|
||||||
|
expect(
|
||||||
|
FriendicaVersion(DateTime(2020, 01), extra: '2'),
|
||||||
|
greaterThan(FriendicaVersion(DateTime(2020, 01), extra: '1')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Newer sub-version alpha for both', () {
|
||||||
|
expect(
|
||||||
|
FriendicaVersion(DateTime(2020, 01), extra: 'b'),
|
||||||
|
greaterThan(FriendicaVersion(DateTime(2020, 01), extra: 'a')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Parsing tests', () {
|
||||||
|
test('Garbled text and null return "unknown"', () {
|
||||||
|
expect(
|
||||||
|
FriendicaVersion.fromVersionString(null),
|
||||||
|
unknown,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
FriendicaVersion.fromVersionString('some version string'),
|
||||||
|
unknown,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
FriendicaVersion.fromVersionString('3.5.1'),
|
||||||
|
unknown,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mastodon version from Mastodon Server', () {
|
||||||
|
final version = FriendicaVersion.fromMastodonVersionString('4.1.0');
|
||||||
|
expect(version, equals(unknown));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mastodon version from Friendica Server', () {
|
||||||
|
final version = FriendicaVersion.fromMastodonVersionString(
|
||||||
|
'2.8.0 (compatible; Friendica 2023.01)');
|
||||||
|
expect(version, equals(FriendicaVersion(DateTime(2023, 01))));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Standard year/month form', () {
|
||||||
|
final version = FriendicaVersion.fromVersionString('2018.09');
|
||||||
|
expect(version, equals(FriendicaVersion(DateTime(2018, 09))));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Standard year/month form with additional version', () {
|
||||||
|
final version = FriendicaVersion.fromVersionString('2020.07-1');
|
||||||
|
expect(version, equals(FriendicaVersion(DateTime(2020, 07), extra: '1')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Test known version strings', () {
|
||||||
|
final versionStrings = [
|
||||||
|
"2.1",
|
||||||
|
"2.2",
|
||||||
|
"2.21",
|
||||||
|
"2.3",
|
||||||
|
"2.32",
|
||||||
|
"2.33",
|
||||||
|
"2.34",
|
||||||
|
"2.35",
|
||||||
|
"2.36",
|
||||||
|
"2.37",
|
||||||
|
"2.38",
|
||||||
|
"2.39",
|
||||||
|
"2.3beta1",
|
||||||
|
"2.3beta2",
|
||||||
|
"3.0",
|
||||||
|
"3.01",
|
||||||
|
"3.1",
|
||||||
|
"3.2",
|
||||||
|
"3.3",
|
||||||
|
"3.3-RC",
|
||||||
|
"3.3.1",
|
||||||
|
"3.3.2",
|
||||||
|
"3.3.3",
|
||||||
|
"3.4",
|
||||||
|
"3.4.1",
|
||||||
|
"3.4.2",
|
||||||
|
"3.4.3",
|
||||||
|
"3.4.3-2",
|
||||||
|
"3.5",
|
||||||
|
"3.5.1",
|
||||||
|
"3.5.2",
|
||||||
|
"3.5.3",
|
||||||
|
"3.5.4",
|
||||||
|
"3.6",
|
||||||
|
"2018.05",
|
||||||
|
"2018.09",
|
||||||
|
"2019.01",
|
||||||
|
"2019.03",
|
||||||
|
"2019.04",
|
||||||
|
"2019.06",
|
||||||
|
"2019.09",
|
||||||
|
"2019.12",
|
||||||
|
"2020.03",
|
||||||
|
"2020.07",
|
||||||
|
"2020.07-1",
|
||||||
|
"2020.09",
|
||||||
|
"2020.09-1",
|
||||||
|
"2021.01",
|
||||||
|
"2021.04",
|
||||||
|
"2021.07",
|
||||||
|
"2021.09",
|
||||||
|
"2022.02",
|
||||||
|
"2022.03",
|
||||||
|
"2022.06",
|
||||||
|
"2022.10",
|
||||||
|
"2022.12",
|
||||||
|
"2023.01",
|
||||||
|
"2023.03",
|
||||||
|
];
|
||||||
|
|
||||||
|
final versions = [
|
||||||
|
unknown, // 2.1
|
||||||
|
unknown, // 2.2
|
||||||
|
unknown, // 2.21
|
||||||
|
unknown, // 2.3
|
||||||
|
unknown, // 2.32
|
||||||
|
unknown, // 2.33
|
||||||
|
unknown, // 2.34
|
||||||
|
unknown, // 2.35
|
||||||
|
unknown, // 2.36
|
||||||
|
unknown, // 2.37
|
||||||
|
unknown, // 2.38
|
||||||
|
unknown, // 2.39
|
||||||
|
unknown, // 2.3beta1
|
||||||
|
unknown, // 2.3beta2
|
||||||
|
unknown, // 3.0
|
||||||
|
unknown, // 3.01
|
||||||
|
unknown, // 3.1
|
||||||
|
unknown, // 3.2
|
||||||
|
unknown, // 3.3
|
||||||
|
unknown, // 3.3-RC
|
||||||
|
unknown, // 3.3.1
|
||||||
|
unknown, // 3.3.2
|
||||||
|
unknown, // 3.3.3
|
||||||
|
unknown, // 3.4
|
||||||
|
unknown, // 3.4.1
|
||||||
|
unknown, // 3.4.2
|
||||||
|
unknown, // 3.4.3
|
||||||
|
unknown, // 3.4.3-2
|
||||||
|
unknown, // 3.5
|
||||||
|
unknown, // 3.5.1
|
||||||
|
unknown, // 3.5.2
|
||||||
|
unknown, // 3.5.3
|
||||||
|
unknown, // 3.5.4
|
||||||
|
unknown, // 3.6
|
||||||
|
v2018_05, // 2018.05
|
||||||
|
v2018_09, // 2018.09
|
||||||
|
v2019_01, // 2019.01
|
||||||
|
v2019_03, // 2019.03
|
||||||
|
v2019_04, // 2019.04
|
||||||
|
v2019_06, // 2019.06
|
||||||
|
v2019_09, // 2019.09
|
||||||
|
v2019_12, // 2019.12
|
||||||
|
v2020_03, // 2020.03
|
||||||
|
v2020_07, // 2020.07
|
||||||
|
v2020_07_01, // 2020.07-1
|
||||||
|
v2020_09, // 2020.09
|
||||||
|
v2020_09_01, // 2020.09-1
|
||||||
|
v2021_01, // 2021.01
|
||||||
|
v2021_04, // 2021.04
|
||||||
|
v2021_07, // 2021.07
|
||||||
|
v2021_09, // 2021.09
|
||||||
|
v2022_02, // 2022.02
|
||||||
|
v2022_03, // 2022.03
|
||||||
|
v2022_06, // 2022.06
|
||||||
|
v2022_10, // 2022.10
|
||||||
|
v2022_12, // 2022.12
|
||||||
|
v2023_01, // 2023.01
|
||||||
|
v2023_03, // 2023.03
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(versionStrings.length, equals(versions.length));
|
||||||
|
for (var i = 0; i < versions.length; i++) {
|
||||||
|
final version = FriendicaVersion.fromVersionString(versionStrings[i]);
|
||||||
|
expect(version, equals(versions[i]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
124
test/html_to_edit_text_helper_test.dart
Normal file
124
test/html_to_edit_text_helper_test.dart
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:relatica/utils/html_to_edit_text_helper.dart';
|
||||||
|
|
||||||
|
void testConversion(String original, String expectedOutput) {
|
||||||
|
final output = toEditTextField(original);
|
||||||
|
if (output != expectedOutput) {
|
||||||
|
print(output);
|
||||||
|
}
|
||||||
|
expect(output, equals(expectedOutput));
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('Empty conversion', () {
|
||||||
|
const original = '';
|
||||||
|
const expected = '';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Plain text no p-tags', () {
|
||||||
|
const original = 'This post is just text';
|
||||||
|
const expected = 'This post is just text';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Plain text with p-tags', () {
|
||||||
|
const original = '<p>This post is just text</p>';
|
||||||
|
const expected = 'This post is just text\n';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Formatting tags', () {
|
||||||
|
const original =
|
||||||
|
'<p>Post with <em>italics</em> <strong>bold</strong> <u>underlined</u></p>';
|
||||||
|
const expected = 'Post with *italics* **bold** <u>underlined</u>\n';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Embedded link', () {
|
||||||
|
const original =
|
||||||
|
"Add preview again<br><a href=\"https://sdtimes.com/software-development/eclipse-foundation-finds-significant-momentum-for-open-source-java-this-year/\" target=\"_blank\" rel=\"noopener noreferrer\">sdtimes.com/software-developme…</a>";
|
||||||
|
const expected = '''
|
||||||
|
Add preview again
|
||||||
|
https://sdtimes.com/software-development/eclipse-foundation-finds-significant-momentum-for-open-source-java-this-year/''';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hashtags and mentions', () {
|
||||||
|
const original =
|
||||||
|
"Post with hashtags <a class=\"mention hashtag status-link\" href=\"https://friendicadevtest1.myportal.social/search?tag=linux\" rel=\"tag\">#<span>linux</span></a> and mentions <a class=\"u-url mention status-link\" href=\"https://friendicadevtest1.myportal.social/profile/testuser2\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"testuser2\">@<span>testuser2</span></a>";
|
||||||
|
const expected =
|
||||||
|
'Post with hashtags #linux and mentions @testuser2@friendicadevtest1.myportal.social';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hashtags within p-tags', () {
|
||||||
|
const original =
|
||||||
|
"<p>Indie requests boops. </p><p><a href=\"https://scicomm.xyz/tags/AcademicDogs\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>AcademicDogs</span></a></p>";
|
||||||
|
const expected = '''
|
||||||
|
Indie requests boops.
|
||||||
|
#AcademicDogs
|
||||||
|
''';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hashtags, links, breaks, and p-tags with unicode', () {
|
||||||
|
const original =
|
||||||
|
"<p>North Dakota 🏴 COVID-19 current stats for Sat Mar 18 2023</p><p>Cases: 286,247<br>Deaths: 2,463<br>Recovered: 278,650<br>Active: 5,134<br>Tests: 2,462,480<br>Doses: 1,307,993</p><p><a href=\"https://mastodon.cloud/tags/covid_north_dakota\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>covid_north_dakota</span></a><br><a href=\"https://covid.yanoagenda.com/states/North%20Dakota\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">covid.yanoagenda.com/states/No</span><span class=\"invisible\">rth%20Dakota</span></a></p>";
|
||||||
|
const expected = '''
|
||||||
|
North Dakota 🏴 COVID-19 current stats for Sat Mar 18 2023
|
||||||
|
Cases: 286,247
|
||||||
|
Deaths: 2,463
|
||||||
|
Recovered: 278,650
|
||||||
|
Active: 5,134
|
||||||
|
Tests: 2,462,480
|
||||||
|
Doses: 1,307,993
|
||||||
|
#covid_north_dakota
|
||||||
|
https://covid.yanoagenda.com/states/North%20Dakota
|
||||||
|
''';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// testPrint(bulletedListWithStuff);
|
||||||
|
// final nestedList =
|
||||||
|
// testPrint(nestedList);
|
||||||
|
|
||||||
|
test('Simple bulleted list', () {
|
||||||
|
const original =
|
||||||
|
"<p>Hello</p><ul class=\"listbullet\" style=\"list-style-type:circle;\"><li>bullet 1</li><li>bullet 2</li></ul>";
|
||||||
|
const expected = '''
|
||||||
|
Hello
|
||||||
|
|
||||||
|
- bullet 1
|
||||||
|
- bullet 2''';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Heavily nested list', () {
|
||||||
|
const original =
|
||||||
|
"<p>List test</p><ul class=\"listbullet\" style=\"list-style-type:circle;\"><li>Level 1 a</li><li>Level 1 b <ul class=\"listbullet\" style=\"list-style-type:circle;\"><li>Level 2 a <ul class=\"listbullet\" style=\"list-style-type:circle;\"><li>Level 3 a</li><li>Level 3 b</li></ul></li><li>Level 2 b</li></ul></li></ul>";
|
||||||
|
const expected = '''
|
||||||
|
List test
|
||||||
|
|
||||||
|
- Level 1 a
|
||||||
|
- Level 1 b
|
||||||
|
- Level 2 a
|
||||||
|
- Level 3 a
|
||||||
|
- Level 3 b
|
||||||
|
- Level 2 b''';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('List with other HTML elements within', () {
|
||||||
|
const original =
|
||||||
|
"<p>Stuff in bulleted list</p><ul class=\"listbullet\" style=\"list-style-type:circle;\"><li>Text with <em>italics</em> <strong>bold</strong> <u>underline</u></li><li>A hyperlink! <a href=\"https://kotlinlang.org/\" target=\"_blank\" rel=\"noopener noreferrer\">kotlinlang.org/</a></li><li>Hashtag <a class=\"mention hashtag status-link\" href=\"https://friendicadevtest1.myportal.social/search?tag=hashtag\" rel=\"tag\">#<span>hashtag</span></a></li><li>Mention <a class=\"u-url mention status-link\" href=\"https://friendicadevtest1.myportal.social/profile/testuser3\" rel=\"noopener noreferrer\" target=\"_blank\" title=\"testuser3\">@<span>testuser3</span></a></li></ul>";
|
||||||
|
const expected = '''
|
||||||
|
Stuff in bulleted list
|
||||||
|
|
||||||
|
- Text with *italics* **bold** <u>underline</u>
|
||||||
|
- A hyperlink! https://kotlinlang.org/
|
||||||
|
- Hashtag #hashtag
|
||||||
|
- Mention @testuser3@friendicadevtest1.myportal.social''';
|
||||||
|
testConversion(original, expected);
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue