diff --git a/lib/controllers/chat_message_controller.dart b/lib/controllers/chat_message_controller.dart index 60984a3..471f877 100644 --- a/lib/controllers/chat_message_controller.dart +++ b/lib/controllers/chat_message_controller.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:provider/provider.dart'; +import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/websocket.dart'; @@ -17,6 +18,7 @@ class ChatMessageController extends ChangeNotifier { late final SnNetworkProvider _sn; late final UserDirectoryProvider _ud; late final WebSocketProvider _ws; + late final SnAttachmentProvider _attach; StreamSubscription? _wsSubscription; @@ -24,6 +26,7 @@ class ChatMessageController extends ChangeNotifier { _sn = context.read(); _ud = context.read(); _ws = context.read(); + _attach = context.read(); } bool isPending = true; @@ -116,12 +119,28 @@ class ChatMessageController extends ChangeNotifier { } Future _addUnconfirmedMessage(SnChatMessage message) async { + final attachmentRid = List.from( + message.body['attachments']?.cast() ?? [], + ); + final attachments = await _attach.getMultiple(attachmentRid); + message = message.copyWith( + preload: SnChatMessagePreload(attachments: attachments), + ); + messages.insert(0, message); unconfirmedMessages.add(message.uuid); notifyListeners(); } Future _addMessage(SnChatMessage message) async { + final attachmentRid = List.from( + message.body['attachments']?.cast() ?? [], + ); + final attachments = await _attach.getMultiple(attachmentRid); + message = message.copyWith( + preload: SnChatMessagePreload(attachments: attachments), + ); + final idx = messages.indexWhere((e) => e.uuid == message.uuid); if (idx != -1) { unconfirmedMessages.remove(message.uuid); @@ -182,7 +201,8 @@ class ChatMessageController extends ChangeNotifier { 'algorithm': 'plain', if (quoteId != null) 'quote_id': quoteId, if (relatedId != null) 'related_id': relatedId, - if (attachments != null) 'attachments': attachments, + if (attachments != null && attachments.isNotEmpty) + 'attachments': attachments, }; // Mock the message locally @@ -257,25 +277,41 @@ class ChatMessageController extends ChangeNotifier { int offset, { bool forceLocal = false, }) async { - if (_box != null) { - // Try retrieve these messages from the local storage - if (_box!.length >= take + offset || forceLocal) { - return _box!.values.skip(offset).take(take).toList(); - } + late List out; + if (_box != null && (_box!.length >= take + offset || forceLocal)) { + out = _box!.values.skip(offset).take(take).toList(); + } else { + final resp = await _sn.client.get( + '/cgi/im/channels/${channel!.keyPath}/events', + queryParameters: { + 'take': take, + 'offset': offset, + }, + ); + messageTotal = resp.data['count'] as int?; + out = List.from( + resp.data['data']?.map((e) => SnChatMessage.fromJson(e)) ?? [], + ); + _saveMessageToLocal(out); } - final resp = await _sn.client.get( - '/cgi/im/channels/${channel!.keyPath}/events', - queryParameters: { - 'take': take, - 'offset': offset, - }, + // Preload attachments + final attachmentRid = List.from( + out.expand((e) => (e.body['attachments'] as List?) ?? []), ); - messageTotal = resp.data['count'] as int?; - final out = List.from( - resp.data['data']?.map((e) => SnChatMessage.fromJson(e)) ?? [], - ); - _saveMessageToLocal(out); + final attachments = await _attach.getMultiple(attachmentRid); + out = out.reversed + .map((ele) => ele.copyWith( + preload: SnChatMessagePreload( + attachments: attachments + .where((e) => + (ele.body['attachments'] as List?) + ?.contains(e) ?? + false) + .toList(), + ), + )) + .toList(); // Preload sender accounts await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet()); diff --git a/lib/providers/sn_attachment.dart b/lib/providers/sn_attachment.dart index d03384d..327e666 100644 --- a/lib/providers/sn_attachment.dart +++ b/lib/providers/sn_attachment.dart @@ -19,6 +19,14 @@ class SnAttachmentProvider { _sn = context.read(); } + void putCache(Iterable items, {bool noCheck = false}) { + for (final item in items) { + if ((item.isAnalyzed && item.isUploaded) || noCheck) { + _cache[item.rid] = item; + } + } + } + Future getOne(String rid, {noCache = false}) async { if (!noCache && _cache.containsKey(rid)) { return _cache[rid]!; @@ -26,37 +34,48 @@ class SnAttachmentProvider { final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta'); final out = SnAttachment.fromJson(resp.data); - _cache[rid] = out; + if (out.isAnalyzed && out.isUploaded) { + _cache[rid] = out; + } return out; } - Future> getMultiple(List rids, + Future> getMultiple(List rids, {noCache = false}) async { - final pendingFetch = - noCache ? rids : rids.where((rid) => !_cache.containsKey(rid)).toList(); + final result = List.filled(rids.length, null); + final Map randomMapping = {}; + for (int i = 0; i < rids.length; i++) { + final rid = rids[i]; + if (noCache || !_cache.containsKey(rid)) { + randomMapping[rid] = i; + } else { + result[i] = _cache[rid]!; + } + } + final pendingFetch = randomMapping.keys; - if (pendingFetch.isEmpty) { - return rids.map((rid) => _cache[rid]!).toList(); + if (pendingFetch.isNotEmpty) { + final resp = await _sn.client.get( + '/cgi/uc/attachments', + queryParameters: { + 'take': pendingFetch.length, + 'id': pendingFetch.join(','), + }, + ); + final out = resp.data['data'] + .map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)) + .toList(); + + for (final item in out) { + if (item.isAnalyzed && item.isUploaded) { + _cache[item.rid] = item; + } + result[randomMapping[item.rid]!] = item; + } } - final resp = await _sn.client.get('/cgi/uc/attachments', queryParameters: { - 'take': pendingFetch.length, - 'id': pendingFetch.join(','), - }); - final out = resp.data['data'] - .where((e) => e['id'] != 0) - .map((e) => SnAttachment.fromJson(e)) - .toList(); - - for (final item in out) { - _cache[item.rid] = item; - } - - return rids - .where((rid) => _cache.containsKey(rid)) - .map((rid) => _cache[rid]!) - .toList(); + return result; } static Map mimetypeOverrides = { diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index e5a0a94..03fcb4d 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -53,7 +53,8 @@ class _ExploreScreenState extends State { preload: SnPostPreload( attachments: attachments .where( - (ele) => out[i].body['attachments']?.contains(ele.rid) ?? false, + (ele) => + out[i].body['attachments']?.contains(ele?.rid) ?? false, ) .toList(), ), diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 3511723..d42a272 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -80,7 +80,6 @@ class _PostEditorScreenState extends State { _writeController.addAttachments( result.map((e) => PostWriteMedia.fromFile(e)), ); - setState(() {}); } @override diff --git a/lib/types/chat.dart b/lib/types/chat.dart index d3208ee..7f61ae9 100644 --- a/lib/types/chat.dart +++ b/lib/types/chat.dart @@ -1,6 +1,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:surface/types/account.dart'; +import 'package:surface/types/attachment.dart'; import 'package:surface/types/realm.dart'; part 'chat.freezed.dart'; @@ -79,8 +80,22 @@ class SnChatMessage with _$SnChatMessage { @HiveField(8) required SnChannelMember sender, @HiveField(9) required int channelId, @HiveField(10) required int senderId, + SnChatMessagePreload? preload, }) = _SnChatMessage; factory SnChatMessage.fromJson(Map json) => _$SnChatMessageFromJson(json); } + +@freezed +class SnChatMessagePreload with _$SnChatMessagePreload { + const SnChatMessagePreload._(); + + @HiveType(typeId: 5) + const factory SnChatMessagePreload({ + @HiveField(0) List? attachments, + }) = _SnChatMessagePreload; + + factory SnChatMessagePreload.fromJson(Map json) => + _$SnChatMessagePreloadFromJson(json); +} diff --git a/lib/types/chat.freezed.dart b/lib/types/chat.freezed.dart index b84509a..fdb3d28 100644 --- a/lib/types/chat.freezed.dart +++ b/lib/types/chat.freezed.dart @@ -1083,6 +1083,7 @@ mixin _$SnChatMessage { int get channelId => throw _privateConstructorUsedError; @HiveField(10) int get senderId => throw _privateConstructorUsedError; + SnChatMessagePreload? get preload => throw _privateConstructorUsedError; /// Serializes this SnChatMessage to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -1111,10 +1112,12 @@ abstract class $SnChatMessageCopyWith<$Res> { @HiveField(7) SnChannel channel, @HiveField(8) SnChannelMember sender, @HiveField(9) int channelId, - @HiveField(10) int senderId}); + @HiveField(10) int senderId, + SnChatMessagePreload? preload}); $SnChannelCopyWith<$Res> get channel; $SnChannelMemberCopyWith<$Res> get sender; + $SnChatMessagePreloadCopyWith<$Res>? get preload; } /// @nodoc @@ -1143,6 +1146,7 @@ class _$SnChatMessageCopyWithImpl<$Res, $Val extends SnChatMessage> Object? sender = null, Object? channelId = null, Object? senderId = null, + Object? preload = freezed, }) { return _then(_value.copyWith( id: null == id @@ -1189,6 +1193,10 @@ class _$SnChatMessageCopyWithImpl<$Res, $Val extends SnChatMessage> ? _value.senderId : senderId // ignore: cast_nullable_to_non_nullable as int, + preload: freezed == preload + ? _value.preload + : preload // ignore: cast_nullable_to_non_nullable + as SnChatMessagePreload?, ) as $Val); } @@ -1211,6 +1219,20 @@ class _$SnChatMessageCopyWithImpl<$Res, $Val extends SnChatMessage> return _then(_value.copyWith(sender: value) as $Val); }); } + + /// Create a copy of SnChatMessage + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $SnChatMessagePreloadCopyWith<$Res>? get preload { + if (_value.preload == null) { + return null; + } + + return $SnChatMessagePreloadCopyWith<$Res>(_value.preload!, (value) { + return _then(_value.copyWith(preload: value) as $Val); + }); + } } /// @nodoc @@ -1232,12 +1254,15 @@ abstract class _$$SnChatMessageImplCopyWith<$Res> @HiveField(7) SnChannel channel, @HiveField(8) SnChannelMember sender, @HiveField(9) int channelId, - @HiveField(10) int senderId}); + @HiveField(10) int senderId, + SnChatMessagePreload? preload}); @override $SnChannelCopyWith<$Res> get channel; @override $SnChannelMemberCopyWith<$Res> get sender; + @override + $SnChatMessagePreloadCopyWith<$Res>? get preload; } /// @nodoc @@ -1264,6 +1289,7 @@ class __$$SnChatMessageImplCopyWithImpl<$Res> Object? sender = null, Object? channelId = null, Object? senderId = null, + Object? preload = freezed, }) { return _then(_$SnChatMessageImpl( id: null == id @@ -1310,6 +1336,10 @@ class __$$SnChatMessageImplCopyWithImpl<$Res> ? _value.senderId : senderId // ignore: cast_nullable_to_non_nullable as int, + preload: freezed == preload + ? _value.preload + : preload // ignore: cast_nullable_to_non_nullable + as SnChatMessagePreload?, )); } } @@ -1329,7 +1359,8 @@ class _$SnChatMessageImpl extends _SnChatMessage { @HiveField(7) required this.channel, @HiveField(8) required this.sender, @HiveField(9) required this.channelId, - @HiveField(10) required this.senderId}) + @HiveField(10) required this.senderId, + this.preload}) : _body = body, super._(); @@ -1375,10 +1406,12 @@ class _$SnChatMessageImpl extends _SnChatMessage { @override @HiveField(10) final int senderId; + @override + final SnChatMessagePreload? preload; @override String toString() { - return 'SnChatMessage(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, uuid: $uuid, body: $body, type: $type, channel: $channel, sender: $sender, channelId: $channelId, senderId: $senderId)'; + return 'SnChatMessage(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, uuid: $uuid, body: $body, type: $type, channel: $channel, sender: $sender, channelId: $channelId, senderId: $senderId, preload: $preload)'; } @override @@ -1401,7 +1434,8 @@ class _$SnChatMessageImpl extends _SnChatMessage { (identical(other.channelId, channelId) || other.channelId == channelId) && (identical(other.senderId, senderId) || - other.senderId == senderId)); + other.senderId == senderId) && + (identical(other.preload, preload) || other.preload == preload)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -1418,7 +1452,8 @@ class _$SnChatMessageImpl extends _SnChatMessage { channel, sender, channelId, - senderId); + senderId, + preload); /// Create a copy of SnChatMessage /// with the given fields replaced by the non-null parameter values. @@ -1448,7 +1483,8 @@ abstract class _SnChatMessage extends SnChatMessage { @HiveField(7) required final SnChannel channel, @HiveField(8) required final SnChannelMember sender, @HiveField(9) required final int channelId, - @HiveField(10) required final int senderId}) = _$SnChatMessageImpl; + @HiveField(10) required final int senderId, + final SnChatMessagePreload? preload}) = _$SnChatMessageImpl; const _SnChatMessage._() : super._(); factory _SnChatMessage.fromJson(Map json) = @@ -1487,6 +1523,8 @@ abstract class _SnChatMessage extends SnChatMessage { @override @HiveField(10) int get senderId; + @override + SnChatMessagePreload? get preload; /// Create a copy of SnChatMessage /// with the given fields replaced by the non-null parameter values. @@ -1495,3 +1533,174 @@ abstract class _SnChatMessage extends SnChatMessage { _$$SnChatMessageImplCopyWith<_$SnChatMessageImpl> get copyWith => throw _privateConstructorUsedError; } + +SnChatMessagePreload _$SnChatMessagePreloadFromJson(Map json) { + return _SnChatMessagePreload.fromJson(json); +} + +/// @nodoc +mixin _$SnChatMessagePreload { + @HiveField(0) + List? get attachments => throw _privateConstructorUsedError; + + /// Serializes this SnChatMessagePreload to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SnChatMessagePreload + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SnChatMessagePreloadCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SnChatMessagePreloadCopyWith<$Res> { + factory $SnChatMessagePreloadCopyWith(SnChatMessagePreload value, + $Res Function(SnChatMessagePreload) then) = + _$SnChatMessagePreloadCopyWithImpl<$Res, SnChatMessagePreload>; + @useResult + $Res call({@HiveField(0) List? attachments}); +} + +/// @nodoc +class _$SnChatMessagePreloadCopyWithImpl<$Res, + $Val extends SnChatMessagePreload> + implements $SnChatMessagePreloadCopyWith<$Res> { + _$SnChatMessagePreloadCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SnChatMessagePreload + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? attachments = freezed, + }) { + return _then(_value.copyWith( + attachments: freezed == attachments + ? _value.attachments + : attachments // ignore: cast_nullable_to_non_nullable + as List?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SnChatMessagePreloadImplCopyWith<$Res> + implements $SnChatMessagePreloadCopyWith<$Res> { + factory _$$SnChatMessagePreloadImplCopyWith(_$SnChatMessagePreloadImpl value, + $Res Function(_$SnChatMessagePreloadImpl) then) = + __$$SnChatMessagePreloadImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({@HiveField(0) List? attachments}); +} + +/// @nodoc +class __$$SnChatMessagePreloadImplCopyWithImpl<$Res> + extends _$SnChatMessagePreloadCopyWithImpl<$Res, _$SnChatMessagePreloadImpl> + implements _$$SnChatMessagePreloadImplCopyWith<$Res> { + __$$SnChatMessagePreloadImplCopyWithImpl(_$SnChatMessagePreloadImpl _value, + $Res Function(_$SnChatMessagePreloadImpl) _then) + : super(_value, _then); + + /// Create a copy of SnChatMessagePreload + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? attachments = freezed, + }) { + return _then(_$SnChatMessagePreloadImpl( + attachments: freezed == attachments + ? _value._attachments + : attachments // ignore: cast_nullable_to_non_nullable + as List?, + )); + } +} + +/// @nodoc +@JsonSerializable() +@HiveType(typeId: 5) +class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload { + const _$SnChatMessagePreloadImpl( + {@HiveField(0) final List? attachments}) + : _attachments = attachments, + super._(); + + factory _$SnChatMessagePreloadImpl.fromJson(Map json) => + _$$SnChatMessagePreloadImplFromJson(json); + + final List? _attachments; + @override + @HiveField(0) + List? get attachments { + final value = _attachments; + if (value == null) return null; + if (_attachments is EqualUnmodifiableListView) return _attachments; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @override + String toString() { + return 'SnChatMessagePreload(attachments: $attachments)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SnChatMessagePreloadImpl && + const DeepCollectionEquality() + .equals(other._attachments, _attachments)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, const DeepCollectionEquality().hash(_attachments)); + + /// Create a copy of SnChatMessagePreload + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SnChatMessagePreloadImplCopyWith<_$SnChatMessagePreloadImpl> + get copyWith => + __$$SnChatMessagePreloadImplCopyWithImpl<_$SnChatMessagePreloadImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SnChatMessagePreloadImplToJson( + this, + ); + } +} + +abstract class _SnChatMessagePreload extends SnChatMessagePreload { + const factory _SnChatMessagePreload( + {@HiveField(0) final List? attachments}) = + _$SnChatMessagePreloadImpl; + const _SnChatMessagePreload._() : super._(); + + factory _SnChatMessagePreload.fromJson(Map json) = + _$SnChatMessagePreloadImpl.fromJson; + + @override + @HiveField(0) + List? get attachments; + + /// Create a copy of SnChatMessagePreload + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SnChatMessagePreloadImplCopyWith<_$SnChatMessagePreloadImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/types/chat.g.dart b/lib/types/chat.g.dart index 7367ea4..fc6daeb 100644 --- a/lib/types/chat.g.dart +++ b/lib/types/chat.g.dart @@ -204,6 +204,41 @@ class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> { typeId == other.typeId; } +class SnChatMessagePreloadImplAdapter + extends TypeAdapter<_$SnChatMessagePreloadImpl> { + @override + final int typeId = 5; + + @override + _$SnChatMessagePreloadImpl read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return _$SnChatMessagePreloadImpl( + attachments: (fields[0] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, _$SnChatMessagePreloadImpl obj) { + writer + ..writeByte(1) + ..writeByte(0) + ..write(obj.attachments); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SnChatMessagePreloadImplAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** @@ -309,6 +344,10 @@ _$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map json) => sender: SnChannelMember.fromJson(json['sender'] as Map), channelId: (json['channel_id'] as num).toInt(), senderId: (json['sender_id'] as num).toInt(), + preload: json['preload'] == null + ? null + : SnChatMessagePreload.fromJson( + json['preload'] as Map), ); Map _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) => @@ -324,4 +363,21 @@ Map _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) => 'sender': instance.sender.toJson(), 'channel_id': instance.channelId, 'sender_id': instance.senderId, + 'preload': instance.preload?.toJson(), + }; + +_$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson( + Map json) => + _$SnChatMessagePreloadImpl( + attachments: (json['attachments'] as List?) + ?.map((e) => e == null + ? null + : SnAttachment.fromJson(e as Map)) + .toList(), + ); + +Map _$$SnChatMessagePreloadImplToJson( + _$SnChatMessagePreloadImpl instance) => + { + 'attachments': instance.attachments?.map((e) => e?.toJson()).toList(), }; diff --git a/lib/types/post.dart b/lib/types/post.dart index b57dfce..4f9301c 100644 --- a/lib/types/post.dart +++ b/lib/types/post.dart @@ -53,7 +53,7 @@ class SnPost with _$SnPost { @freezed class SnPostPreload with _$SnPostPreload { const factory SnPostPreload({ - required List? attachments, + required List? attachments, }) = _SnPostPreload; factory SnPostPreload.fromJson(Map json) => diff --git a/lib/types/post.freezed.dart b/lib/types/post.freezed.dart index 507556e..8b00e4e 100644 --- a/lib/types/post.freezed.dart +++ b/lib/types/post.freezed.dart @@ -953,7 +953,7 @@ SnPostPreload _$SnPostPreloadFromJson(Map json) { /// @nodoc mixin _$SnPostPreload { - List? get attachments => throw _privateConstructorUsedError; + List? get attachments => throw _privateConstructorUsedError; /// Serializes this SnPostPreload to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -971,7 +971,7 @@ abstract class $SnPostPreloadCopyWith<$Res> { SnPostPreload value, $Res Function(SnPostPreload) then) = _$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>; @useResult - $Res call({List? attachments}); + $Res call({List? attachments}); } /// @nodoc @@ -995,7 +995,7 @@ class _$SnPostPreloadCopyWithImpl<$Res, $Val extends SnPostPreload> attachments: freezed == attachments ? _value.attachments : attachments // ignore: cast_nullable_to_non_nullable - as List?, + as List?, ) as $Val); } } @@ -1008,7 +1008,7 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res> __$$SnPostPreloadImplCopyWithImpl<$Res>; @override @useResult - $Res call({List? attachments}); + $Res call({List? attachments}); } /// @nodoc @@ -1030,7 +1030,7 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res> attachments: freezed == attachments ? _value._attachments : attachments // ignore: cast_nullable_to_non_nullable - as List?, + as List?, )); } } @@ -1038,15 +1038,15 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$SnPostPreloadImpl implements _SnPostPreload { - const _$SnPostPreloadImpl({required final List? attachments}) + const _$SnPostPreloadImpl({required final List? attachments}) : _attachments = attachments; factory _$SnPostPreloadImpl.fromJson(Map json) => _$$SnPostPreloadImplFromJson(json); - final List? _attachments; + final List? _attachments; @override - List? get attachments { + List? get attachments { final value = _attachments; if (value == null) return null; if (_attachments is EqualUnmodifiableListView) return _attachments; @@ -1091,13 +1091,13 @@ class _$SnPostPreloadImpl implements _SnPostPreload { abstract class _SnPostPreload implements SnPostPreload { const factory _SnPostPreload( - {required final List? attachments}) = _$SnPostPreloadImpl; + {required final List? attachments}) = _$SnPostPreloadImpl; factory _SnPostPreload.fromJson(Map json) = _$SnPostPreloadImpl.fromJson; @override - List? get attachments; + List? get attachments; /// Create a copy of SnPostPreload /// with the given fields replaced by the non-null parameter values. diff --git a/lib/types/post.g.dart b/lib/types/post.g.dart index c3e07ab..83ba11f 100644 --- a/lib/types/post.g.dart +++ b/lib/types/post.g.dart @@ -103,13 +103,15 @@ Map _$$SnPostImplToJson(_$SnPostImpl instance) => _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map json) => _$SnPostPreloadImpl( attachments: (json['attachments'] as List?) - ?.map((e) => SnAttachment.fromJson(e as Map)) + ?.map((e) => e == null + ? null + : SnAttachment.fromJson(e as Map)) .toList(), ); Map _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) => { - 'attachments': instance.attachments?.map((e) => e.toJson()).toList(), + 'attachments': instance.attachments?.map((e) => e?.toJson()).toList(), }; _$SnBodyImpl _$$SnBodyImplFromJson(Map json) => _$SnBodyImpl( diff --git a/lib/widgets/attachment/attachment_item.dart b/lib/widgets/attachment/attachment_item.dart index 04a7dd6..941e18e 100644 --- a/lib/widgets/attachment/attachment_item.dart +++ b/lib/widgets/attachment/attachment_item.dart @@ -14,7 +14,7 @@ import 'package:surface/widgets/universal_image.dart'; import 'package:uuid/uuid.dart'; class AttachmentItem extends StatelessWidget { - final SnAttachment data; + final SnAttachment? data; final bool isExpandable; const AttachmentItem({ super.key, @@ -23,15 +23,19 @@ class AttachmentItem extends StatelessWidget { }); Widget _buildContent(BuildContext context, String heroTag) { - final tp = data.mimetype.split('/').firstOrNull; + if (data == null) { + return const Icon(Symbols.cancel).center(); + } + + final tp = data!.mimetype.split('/').firstOrNull; final sn = context.read(); switch (tp) { case 'image': return Hero( - tag: 'attachment-${data.rid}-$heroTag', + tag: 'attachment-${data!.rid}-$heroTag', child: AutoResizeUniversalImage( - sn.getAttachmentUrl(data.rid), - key: Key('attachment-${data.rid}-$heroTag'), + sn.getAttachmentUrl(data!.rid), + key: Key('attachment-${data!.rid}-$heroTag'), fit: BoxFit.cover, ), ); @@ -45,7 +49,7 @@ class AttachmentItem extends StatelessWidget { final uuid = Uuid(); final heroTag = uuid.v4(); - if (data.isMature) { + if (data!.isMature) { return _AttachmentItemSensitiveBlur( child: _buildContent(context, heroTag), ); @@ -56,7 +60,7 @@ class AttachmentItem extends StatelessWidget { child: _buildContent(context, heroTag), onTap: () { context.pushTransparentRoute( - AttachmentDetailPopup(data: data, heroTag: heroTag), + AttachmentDetailPopup(data: data!, heroTag: heroTag), rootNavigator: true, ); }, diff --git a/lib/widgets/attachment/attachment_list.dart b/lib/widgets/attachment/attachment_list.dart index ca975b9..6015ab3 100644 --- a/lib/widgets/attachment/attachment_list.dart +++ b/lib/widgets/attachment/attachment_list.dart @@ -6,7 +6,7 @@ import 'package:surface/types/attachment.dart'; import 'package:surface/widgets/attachment/attachment_item.dart'; class AttachmentList extends StatelessWidget { - final List data; + final List data; final bool? bordered; final double? maxHeight; final EdgeInsets? listPadding; @@ -46,7 +46,7 @@ class AttachmentList extends StatelessWidget { borderRadius: kDefaultRadius, ), child: AspectRatio( - aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1, + aspectRatio: data[0]?.metadata['ratio']?.toDouble() ?? 1, child: ClipRRect( borderRadius: kDefaultRadius, child: AttachmentItem(data: data[0], isExpandable: true), @@ -62,7 +62,7 @@ class AttachmentList extends StatelessWidget { border: Border(top: borderSide, bottom: borderSide), ), child: AspectRatio( - aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1, + aspectRatio: data[0]?.metadata['ratio']?.toDouble() ?? 1, child: AttachmentItem(data: data[0], isExpandable: true), ), ); @@ -86,7 +86,7 @@ class AttachmentList extends StatelessWidget { borderRadius: kDefaultRadius, ), child: AspectRatio( - aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1, + aspectRatio: data[idx]?.metadata['ratio']?.toDouble() ?? 1, child: ClipRRect( borderRadius: kDefaultRadius, child: diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart index ef55c08..647d93f 100644 --- a/lib/widgets/chat/chat_message.dart +++ b/lib/widgets/chat/chat_message.dart @@ -6,6 +6,7 @@ import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/user_directory.dart'; import 'package:surface/types/chat.dart'; import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/markdown_content.dart'; class ChatMessage extends StatelessWidget { @@ -63,6 +64,13 @@ class ChatMessage extends StatelessWidget { content: data.body['text'], isAutoWarp: true, ), + if (data.preload?.attachments?.isNotEmpty ?? false) + AttachmentList( + data: data.preload!.attachments!, + bordered: true, + maxHeight: 520, + listPadding: const EdgeInsets.symmetric(horizontal: 12), + ), if (!hasMerged) const Gap(8), ], ), diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index 987ea3a..def0b6d 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -1,9 +1,15 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/chat_message_controller.dart'; +import 'package:surface/controllers/post_write_controller.dart'; +import 'package:surface/providers/sn_attachment.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/post/post_media_pending_list.dart'; class ChatMessageInput extends StatefulWidget { final ChatMessageController controller; @@ -14,15 +20,83 @@ class ChatMessageInput extends StatefulWidget { } class _ChatMessageInputState extends State { + bool _isBusy = false; + double? _progress; + final TextEditingController _contentController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - void _sendMessage() { + Future _sendMessage() async { + if (_isBusy) return; + + final attach = context.read(); + + setState(() => _isBusy = true); + + try { + for (int i = 0; i < _attachments.length; i++) { + final media = _attachments[i]; + if (media.attachment != null) continue; // Already uploaded, skip + if (media.isEmpty) continue; // Nothing to do, skip + + final place = await attach.chunkedUploadInitialize( + (await media.length())!, + media.name, + 'interactive', + null, + ); + + final item = await attach.chunkedUploadParts( + media.toFile()!, + place.$1, + place.$2, + onProgress: (progress) { + // Calculate overall progress for attachments + setState(() { + progress = (i + progress) / _attachments.length; + }); + }, + ); + + _attachments[i] = PostWriteMedia(item); + } + } catch (err) { + if (!mounted) return; + setState(() => _isBusy = false); + context.showErrorDialog(err); + return; + } + + attach.putCache( + _attachments.where((e) => e.attachment != null).map((e) => e.attachment!), + ); + + // Send the message + // NOTICE This future should not be awaited, so that the message can be sent in the background and the user can continue to type widget.controller.sendMessage( 'messages.new', _contentController.text, + attachments: _attachments + .where((e) => e.attachment != null) + .map((e) => e.attachment!.rid) + .toList(), ); _contentController.clear(); + _attachments.clear(); + + setState(() => _isBusy = false); + } + + final List _attachments = List.empty(growable: true); + final _imagePicker = ImagePicker(); + + void _selectMedia() async { + final result = await _imagePicker.pickMultipleMedia(); + if (result.isEmpty) return; + _attachments.addAll( + result.map((e) => PostWriteMedia.fromFile(e)), + ); + setState(() {}); } @override @@ -37,6 +111,33 @@ class _ChatMessageInputState extends State { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ + if (_isBusy && _progress != null) + TweenAnimationBuilder( + tween: Tween(begin: 0, end: _progress), + duration: Duration(milliseconds: 300), + builder: (context, value, _) => + LinearProgressIndicator(value: value, minHeight: 2), + ) + else if (_isBusy) + const LinearProgressIndicator(value: null, minHeight: 2), + Padding( + padding: _attachments.isNotEmpty + ? const EdgeInsets.only(top: 8) + : EdgeInsets.zero, + child: PostMediaPendingListRaw( + attachments: _attachments, + isBusy: _isBusy, + onUpdate: (idx, updatedMedia) async { + setState(() => _attachments[idx] = updatedMedia); + }, + onRemove: (idx) async { + setState(() => _attachments.removeAt(idx)); + }, + onUpdateBusy: (state) => setState(() => _isBusy = state), + ).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate( + const Duration(milliseconds: 300), + Curves.fastEaseInToSlowEaseOut), + ), SizedBox( height: 56, child: Row( @@ -53,6 +154,7 @@ class _ChatMessageInputState extends State { border: InputBorder.none, ), onSubmitted: (_) { + if (_isBusy) return; _sendMessage(); _focusNode.requestFocus(); }, @@ -60,7 +162,14 @@ class _ChatMessageInputState extends State { ), const Gap(8), IconButton( - onPressed: _sendMessage, + onPressed: _isBusy ? null : _selectMedia, + icon: Icon( + Symbols.add_photo_alternate, + color: Theme.of(context).colorScheme.primary, + ), + ), + IconButton( + onPressed: _isBusy ? null : _sendMessage, icon: Icon( Symbols.send, color: Theme.of(context).colorScheme.primary, diff --git a/lib/widgets/post/post_comment_list.dart b/lib/widgets/post/post_comment_list.dart index 46c0ff0..967d880 100644 --- a/lib/widgets/post/post_comment_list.dart +++ b/lib/widgets/post/post_comment_list.dart @@ -61,7 +61,8 @@ class PostCommentSliverListState extends State { preload: SnPostPreload( attachments: attachments .where( - (ele) => out[i].body['attachments']?.contains(ele.rid) ?? false, + (ele) => + out[i].body['attachments']?.contains(ele?.rid) ?? false, ) .toList(), ), diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart index 976f094..34a9c99 100644 --- a/lib/widgets/post/post_media_pending_list.dart +++ b/lib/widgets/post/post_media_pending_list.dart @@ -18,54 +18,22 @@ import 'package:surface/widgets/dialog.dart'; class PostMediaPendingList extends StatelessWidget { final PostWriteController controller; + const PostMediaPendingList({super.key, required this.controller}); - void _cropImage(BuildContext context, int idx) async { - final media = controller.attachments[idx]; - final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) - ? await showCupertinoImageCropper( - // ignore: use_build_context_synchronously - context, - // ignore: use_build_context_synchronously - imageProvider: media.getImageProvider(context)!, - ) - : await showMaterialImageCropper( - // ignore: use_build_context_synchronously - context, - // ignore: use_build_context_synchronously - imageProvider: media.getImageProvider(context)!, - ); - - if (result == null) return; - if (!context.mounted) return; - + Future _handleUpdate(int idx, PostWriteMedia updatedMedia) async { controller.setIsBusy(true); - - final rawBytes = - (await result.uiImage.toByteData(format: ImageByteFormat.png))! - .buffer - .asUint8List(); - controller.setAttachmentAt( - idx, - PostWriteMedia.fromBytes(rawBytes, media.name, media.type), - ); - - controller.setIsBusy(false); + try { + controller.setAttachmentAt(idx, updatedMedia); + } finally { + controller.setIsBusy(false); + } } - void _deleteAttachment(BuildContext context, int idx) async { - final media = controller.attachments[idx]; - if (media.attachment == null) return; - + Future _handleRemove(int idx) async { controller.setIsBusy(true); - try { - final sn = context.read(); - await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}'); controller.removeAttachmentAt(idx); - } catch (err) { - if (!context.mounted) return; - context.showErrorDialog(err); } finally { controller.setIsBusy(false); } @@ -73,108 +41,180 @@ class PostMediaPendingList extends StatelessWidget { @override Widget build(BuildContext context) { - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; - return ListenableBuilder( listenable: controller, builder: (context, _) { - return Container( - constraints: const BoxConstraints(maxHeight: 120), - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - separatorBuilder: (context, index) => const Gap(8), - itemCount: controller.attachments.length, - itemBuilder: (context, idx) { - final media = controller.attachments[idx]; - return ContextMenuRegion( - contextMenu: ContextMenu( - entries: [ - if (media.type == PostWriteMediaType.image && - media.attachment != null) - MenuItem( - label: 'preview'.tr(), - icon: Symbols.preview, - onSelected: () { - context.pushTransparentRoute( - AttachmentDetailPopup(data: media.attachment!), - rootNavigator: true, - ); - }, - ), - if (media.type == PostWriteMediaType.image && - media.attachment == null) - MenuItem( - label: 'crop'.tr(), - icon: Symbols.crop, - onSelected: () => _cropImage(context, idx), - ), - if (media.attachment != null) - MenuItem( - label: 'delete'.tr(), - icon: Symbols.delete, - onSelected: controller.isBusy - ? null - : () => _deleteAttachment(context, idx), - ), - if (media.attachment == null) - MenuItem( - label: 'delete'.tr(), - icon: Symbols.delete, - onSelected: () { - controller.removeAttachmentAt(idx); - }, - ) - else - MenuItem( - label: 'unlink'.tr(), - icon: Symbols.link_off, - onSelected: () { - controller.removeAttachmentAt(idx); - }, - ), - ], - ), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), - borderRadius: BorderRadius.circular(8), - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: AspectRatio( - aspectRatio: 1, - child: switch (media.type) { - PostWriteMediaType.image => - LayoutBuilder(builder: (context, constraints) { - return Image( - image: media.getImageProvider( - context, - width: (constraints.maxWidth * devicePixelRatio) - .round(), - height: - (constraints.maxHeight * devicePixelRatio) - .round(), - )!, - fit: BoxFit.cover, - ); - }), - _ => Container( - color: Theme.of(context).colorScheme.surface, - child: const Icon(Symbols.docs).center(), - ), - }, - ), - ), - ), - ); - }, - ), + return PostMediaPendingListRaw( + attachments: controller.attachments, + isBusy: controller.isBusy, + onUpdate: (idx, updatedMedia) => _handleUpdate(idx, updatedMedia), + onRemove: (idx) => _handleRemove(idx), + onUpdateBusy: (state) => controller.setIsBusy(state), ); }, ); } } + +class PostMediaPendingListRaw extends StatelessWidget { + final List attachments; + final bool isBusy; + final Future Function(int idx, PostWriteMedia updatedMedia)? onUpdate; + final Future Function(int idx)? onRemove; + final void Function(bool state)? onUpdateBusy; + + const PostMediaPendingListRaw({ + super.key, + required this.attachments, + required this.isBusy, + this.onUpdate, + this.onRemove, + this.onUpdateBusy, + }); + + Future _cropImage(BuildContext context, int idx) async { + final media = attachments[idx]; + final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) + ? await showCupertinoImageCropper( + context, + imageProvider: media.getImageProvider(context)!, + ) + : await showMaterialImageCropper( + context, + imageProvider: media.getImageProvider(context)!, + ); + + if (result == null) return; + + final rawBytes = + (await result.uiImage.toByteData(format: ImageByteFormat.png))! + .buffer + .asUint8List(); + + if (onUpdate != null) { + final updatedMedia = PostWriteMedia.fromBytes( + rawBytes, + media.name, + media.type, + ); + await onUpdate!(idx, updatedMedia); + } + } + + Future _deleteAttachment(BuildContext context, int idx) async { + final media = attachments[idx]; + if (media.attachment == null) return; + + try { + onUpdateBusy?.call(true); + final sn = context.read(); + await sn.client.delete('/cgi/uc/attachments/${media.attachment!.id}'); + onRemove!(idx); + } catch (err) { + if (!context.mounted) return; + context.showErrorDialog(err); + } finally { + onUpdateBusy?.call(false); + } + } + + @override + Widget build(BuildContext context) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + + return Container( + constraints: const BoxConstraints(maxHeight: 120), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + separatorBuilder: (context, index) => const Gap(8), + itemCount: attachments.length, + itemBuilder: (context, idx) { + final media = attachments[idx]; + return ContextMenuRegion( + contextMenu: ContextMenu( + entries: [ + if (media.type == PostWriteMediaType.image && + media.attachment != null) + MenuItem( + label: 'preview'.tr(), + icon: Symbols.preview, + onSelected: () { + context.pushTransparentRoute( + AttachmentDetailPopup(data: media.attachment!), + rootNavigator: true, + ); + }, + ), + if (media.type == PostWriteMediaType.image && + media.attachment == null) + MenuItem( + label: 'crop'.tr(), + icon: Symbols.crop, + onSelected: () => _cropImage(context, idx), + ), + if (media.attachment != null && onRemove != null) + MenuItem( + label: 'delete'.tr(), + icon: Symbols.delete, + onSelected: + isBusy ? null : () => _deleteAttachment(context, idx), + ), + if (media.attachment == null && onRemove != null) + MenuItem( + label: 'delete'.tr(), + icon: Symbols.delete, + onSelected: () { + onRemove!(idx); + }, + ) + else if (onRemove != null) + MenuItem( + label: 'unlink'.tr(), + icon: Symbols.link_off, + onSelected: () { + onRemove!(idx); + }, + ), + ], + ), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + width: 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 1, + child: switch (media.type) { + PostWriteMediaType.image => + LayoutBuilder(builder: (context, constraints) { + return Image( + image: media.getImageProvider( + context, + width: (constraints.maxWidth * devicePixelRatio) + .round(), + height: (constraints.maxHeight * devicePixelRatio) + .round(), + )!, + fit: BoxFit.cover, + ); + }), + _ => Container( + color: Theme.of(context).colorScheme.surface, + child: const Icon(Symbols.docs).center(), + ), + }, + ), + ), + ), + ); + }, + ), + ); + } +}