♻️ Refactored attachment cache

This commit is contained in:
LittleSheep 2024-11-18 00:55:39 +08:00
parent 432705c570
commit 359cd94532
16 changed files with 712 additions and 213 deletions

View File

@ -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<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_ws = context.read<WebSocketProvider>();
_attach = context.read<SnAttachmentProvider>();
}
bool isPending = true;
@ -116,12 +119,28 @@ class ChatMessageController extends ChangeNotifier {
}
Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
final attachmentRid = List<String>.from(
message.body['attachments']?.cast<String>() ?? [],
);
final attachments = await _attach.getMultiple(attachmentRid);
message = message.copyWith(
preload: SnChatMessagePreload(attachments: attachments),
);
messages.insert(0, message);
unconfirmedMessages.add(message.uuid);
notifyListeners();
}
Future<void> _addMessage(SnChatMessage message) async {
final attachmentRid = List<String>.from(
message.body['attachments']?.cast<String>() ?? [],
);
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<SnChatMessage> 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<SnChatMessage>.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<String>.from(
out.expand((e) => (e.body['attachments'] as List<dynamic>?) ?? []),
);
messageTotal = resp.data['count'] as int?;
final out = List<SnChatMessage>.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<dynamic>?)
?.contains(e) ??
false)
.toList(),
),
))
.toList();
// Preload sender accounts
await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());

View File

@ -19,6 +19,14 @@ class SnAttachmentProvider {
_sn = context.read<SnNetworkProvider>();
}
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
for (final item in items) {
if ((item.isAnalyzed && item.isUploaded) || noCheck) {
_cache[item.rid] = item;
}
}
}
Future<SnAttachment> 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<List<SnAttachment>> getMultiple(List<String> rids,
Future<List<SnAttachment?>> getMultiple(List<String> rids,
{noCache = false}) async {
final pendingFetch =
noCache ? rids : rids.where((rid) => !_cache.containsKey(rid)).toList();
final result = List<SnAttachment?>.filled(rids.length, null);
final Map<String, int> 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<String, String> mimetypeOverrides = {

View File

@ -53,7 +53,8 @@ class _ExploreScreenState extends State<ExploreScreen> {
preload: SnPostPreload(
attachments: attachments
.where(
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
(ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false,
)
.toList(),
),

View File

@ -80,7 +80,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
_writeController.addAttachments(
result.map((e) => PostWriteMedia.fromFile(e)),
);
setState(() {});
}
@override

View File

@ -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<String, dynamic> json) =>
_$SnChatMessageFromJson(json);
}
@freezed
class SnChatMessagePreload with _$SnChatMessagePreload {
const SnChatMessagePreload._();
@HiveType(typeId: 5)
const factory SnChatMessagePreload({
@HiveField(0) List<SnAttachment?>? attachments,
}) = _SnChatMessagePreload;
factory SnChatMessagePreload.fromJson(Map<String, dynamic> json) =>
_$SnChatMessagePreloadFromJson(json);
}

View File

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return _SnChatMessagePreload.fromJson(json);
}
/// @nodoc
mixin _$SnChatMessagePreload {
@HiveField(0)
List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
/// Serializes this SnChatMessagePreload to a JSON map.
Map<String, dynamic> 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<SnChatMessagePreload> 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<SnAttachment?>? 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<SnAttachment?>?,
) 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<SnAttachment?>? 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<SnAttachment?>?,
));
}
}
/// @nodoc
@JsonSerializable()
@HiveType(typeId: 5)
class _$SnChatMessagePreloadImpl extends _SnChatMessagePreload {
const _$SnChatMessagePreloadImpl(
{@HiveField(0) final List<SnAttachment?>? attachments})
: _attachments = attachments,
super._();
factory _$SnChatMessagePreloadImpl.fromJson(Map<String, dynamic> json) =>
_$$SnChatMessagePreloadImplFromJson(json);
final List<SnAttachment?>? _attachments;
@override
@HiveField(0)
List<SnAttachment?>? 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<String, dynamic> toJson() {
return _$$SnChatMessagePreloadImplToJson(
this,
);
}
}
abstract class _SnChatMessagePreload extends SnChatMessagePreload {
const factory _SnChatMessagePreload(
{@HiveField(0) final List<SnAttachment?>? attachments}) =
_$SnChatMessagePreloadImpl;
const _SnChatMessagePreload._() : super._();
factory _SnChatMessagePreload.fromJson(Map<String, dynamic> json) =
_$SnChatMessagePreloadImpl.fromJson;
@override
@HiveField(0)
List<SnAttachment?>? 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;
}

View File

@ -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 = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return _$SnChatMessagePreloadImpl(
attachments: (fields[0] as List?)?.cast<SnAttachment?>(),
);
}
@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<String, dynamic> json) =>
sender: SnChannelMember.fromJson(json['sender'] as Map<String, dynamic>),
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<String, dynamic>),
);
Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
@ -324,4 +363,21 @@ Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
'sender': instance.sender.toJson(),
'channel_id': instance.channelId,
'sender_id': instance.senderId,
'preload': instance.preload?.toJson(),
};
_$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
Map<String, dynamic> json) =>
_$SnChatMessagePreloadImpl(
attachments: (json['attachments'] as List<dynamic>?)
?.map((e) => e == null
? null
: SnAttachment.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
_$SnChatMessagePreloadImpl instance) =>
<String, dynamic>{
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
};

View File

@ -53,7 +53,7 @@ class SnPost with _$SnPost {
@freezed
class SnPostPreload with _$SnPostPreload {
const factory SnPostPreload({
required List<SnAttachment>? attachments,
required List<SnAttachment?>? attachments,
}) = _SnPostPreload;
factory SnPostPreload.fromJson(Map<String, Object?> json) =>

View File

@ -953,7 +953,7 @@ SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
/// @nodoc
mixin _$SnPostPreload {
List<SnAttachment>? get attachments => throw _privateConstructorUsedError;
List<SnAttachment?>? get attachments => throw _privateConstructorUsedError;
/// Serializes this SnPostPreload to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -971,7 +971,7 @@ abstract class $SnPostPreloadCopyWith<$Res> {
SnPostPreload value, $Res Function(SnPostPreload) then) =
_$SnPostPreloadCopyWithImpl<$Res, SnPostPreload>;
@useResult
$Res call({List<SnAttachment>? attachments});
$Res call({List<SnAttachment?>? 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<SnAttachment>?,
as List<SnAttachment?>?,
) as $Val);
}
}
@ -1008,7 +1008,7 @@ abstract class _$$SnPostPreloadImplCopyWith<$Res>
__$$SnPostPreloadImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({List<SnAttachment>? attachments});
$Res call({List<SnAttachment?>? attachments});
}
/// @nodoc
@ -1030,7 +1030,7 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
attachments: freezed == attachments
? _value._attachments
: attachments // ignore: cast_nullable_to_non_nullable
as List<SnAttachment>?,
as List<SnAttachment?>?,
));
}
}
@ -1038,15 +1038,15 @@ class __$$SnPostPreloadImplCopyWithImpl<$Res>
/// @nodoc
@JsonSerializable()
class _$SnPostPreloadImpl implements _SnPostPreload {
const _$SnPostPreloadImpl({required final List<SnAttachment>? attachments})
const _$SnPostPreloadImpl({required final List<SnAttachment?>? attachments})
: _attachments = attachments;
factory _$SnPostPreloadImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPostPreloadImplFromJson(json);
final List<SnAttachment>? _attachments;
final List<SnAttachment?>? _attachments;
@override
List<SnAttachment>? get attachments {
List<SnAttachment?>? 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<SnAttachment>? attachments}) = _$SnPostPreloadImpl;
{required final List<SnAttachment?>? attachments}) = _$SnPostPreloadImpl;
factory _SnPostPreload.fromJson(Map<String, dynamic> json) =
_$SnPostPreloadImpl.fromJson;
@override
List<SnAttachment>? get attachments;
List<SnAttachment?>? get attachments;
/// Create a copy of SnPostPreload
/// with the given fields replaced by the non-null parameter values.

View File

@ -103,13 +103,15 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
_$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
_$SnPostPreloadImpl(
attachments: (json['attachments'] as List<dynamic>?)
?.map((e) => SnAttachment.fromJson(e as Map<String, dynamic>))
?.map((e) => e == null
? null
: SnAttachment.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SnPostPreloadImplToJson(_$SnPostPreloadImpl instance) =>
<String, dynamic>{
'attachments': instance.attachments?.map((e) => e.toJson()).toList(),
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
};
_$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl(

View File

@ -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<SnNetworkProvider>();
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,
);
},

View File

@ -6,7 +6,7 @@ import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/attachment/attachment_item.dart';
class AttachmentList extends StatelessWidget {
final List<SnAttachment> data;
final List<SnAttachment?> 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:

View File

@ -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),
],
),

View File

@ -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<ChatMessageInput> {
bool _isBusy = false;
double? _progress;
final TextEditingController _contentController = TextEditingController();
final FocusNode _focusNode = FocusNode();
void _sendMessage() {
Future<void> _sendMessage() async {
if (_isBusy) return;
final attach = context.read<SnAttachmentProvider>();
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<PostWriteMedia> _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<ChatMessageInput> {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isBusy && _progress != null)
TweenAnimationBuilder<double>(
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<ChatMessageInput> {
border: InputBorder.none,
),
onSubmitted: (_) {
if (_isBusy) return;
_sendMessage();
_focusNode.requestFocus();
},
@ -60,7 +162,14 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
),
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,

View File

@ -61,7 +61,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
preload: SnPostPreload(
attachments: attachments
.where(
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
(ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false,
)
.toList(),
),

View File

@ -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<void> _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<void> _handleRemove(int idx) async {
controller.setIsBusy(true);
try {
final sn = context.read<SnNetworkProvider>();
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<PostWriteMedia> attachments;
final bool isBusy;
final Future<void> Function(int idx, PostWriteMedia updatedMedia)? onUpdate;
final Future<void> 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<void> _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<void> _deleteAttachment(BuildContext context, int idx) async {
final media = attachments[idx];
if (media.attachment == null) return;
try {
onUpdateBusy?.call(true);
final sn = context.read<SnNetworkProvider>();
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(),
),
},
),
),
),
);
},
),
);
}
}