Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
cb05ff2e9e | |||
f614da7918 | |||
a3c8dafff9 | |||
fa978a7cd1 | |||
aaa0a562b4 | |||
590a4ce2a6 |
@ -281,6 +281,10 @@
|
||||
"one": "{} attachment",
|
||||
"other": "{} attachments"
|
||||
},
|
||||
"messageTyping": {
|
||||
"one": "{} is typing...",
|
||||
"other": "{} are typing..."
|
||||
},
|
||||
"fieldAttachmentRandomId": "Random ID",
|
||||
"fieldAttachmentAlt": "Alternative text",
|
||||
"addAttachmentFromAlbum": "Add from album",
|
||||
|
@ -279,6 +279,10 @@
|
||||
"one": "{} 个附件",
|
||||
"other": "{} 个附件"
|
||||
},
|
||||
"messageTyping": {
|
||||
"one": "{} 正在输入",
|
||||
"other": "{} 正在输入"
|
||||
},
|
||||
"fieldAttachmentRandomId": "访问 ID",
|
||||
"fieldAttachmentAlt": "概述文字",
|
||||
"addAttachmentFromAlbum": "从相册中添加附件",
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
@ -11,6 +12,7 @@ import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/websocket.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChatMessageController extends ChangeNotifier {
|
||||
@ -36,8 +38,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
|
||||
int? messageTotal;
|
||||
|
||||
bool get isAllLoaded =>
|
||||
messageTotal != null && messages.length >= messageTotal!;
|
||||
bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!;
|
||||
|
||||
String? _boxKey;
|
||||
SnChannel? channel;
|
||||
@ -50,8 +51,10 @@ class ChatMessageController extends ChangeNotifier {
|
||||
/// Stored as a list of nonce to provide the loading state
|
||||
final List<String> unconfirmedMessages = List.empty(growable: true);
|
||||
|
||||
Box<SnChatMessage>? get _box =>
|
||||
(_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
|
||||
Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
|
||||
|
||||
final List<SnChannelMember> typingMembers = List.empty(growable: true);
|
||||
final Map<int, Timer> typingInactiveTimer = {};
|
||||
|
||||
Future<void> initialize(SnChannel chan) async {
|
||||
channel = chan;
|
||||
@ -78,22 +81,17 @@ class ChatMessageController extends ChangeNotifier {
|
||||
if (event.payload?['channel_id'] != channel?.id) break;
|
||||
final member = SnChannelMember.fromJson(event.payload!['member']);
|
||||
if (member.id == profile?.id) break;
|
||||
// TODO impl typing users
|
||||
// if (!_typingUsers.any((x) => x.id == member.id)) {
|
||||
// setState(() {
|
||||
// _typingUsers.add(member);
|
||||
// });
|
||||
// }
|
||||
// _typingInactiveTimer[member.id]?.cancel();
|
||||
// _typingInactiveTimer[member.id] = Timer(
|
||||
// const Duration(seconds: 3),
|
||||
// () {
|
||||
// setState(() {
|
||||
// _typingUsers.removeWhere((x) => x.id == member.id);
|
||||
// _typingInactiveTimer.remove(member.id);
|
||||
// });
|
||||
// },
|
||||
// );
|
||||
if (!typingMembers.any((x) => x.id == member.id)) {
|
||||
typingMembers.add(member);
|
||||
print('Typing member: ${typingMembers.map((ele) => member.id)}');
|
||||
notifyListeners();
|
||||
}
|
||||
typingInactiveTimer[member.id]?.cancel();
|
||||
typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () {
|
||||
typingMembers.removeWhere((x) => x.id == member.id);
|
||||
typingInactiveTimer.remove(member.id);
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -101,6 +99,35 @@ class ChatMessageController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Timer? _typingNotifyTimer;
|
||||
bool _typingStatus = false;
|
||||
|
||||
Future<void> _sendTypingStatusPackage() async {
|
||||
_ws.conn?.sink.add(jsonEncode(
|
||||
WebSocketPackage(
|
||||
method: 'status.typing',
|
||||
endpoint: 'im',
|
||||
payload: {
|
||||
'channel_id': channel!.id,
|
||||
},
|
||||
).toJson(),
|
||||
));
|
||||
}
|
||||
|
||||
void pingTypingStatus() {
|
||||
if (!_typingStatus) {
|
||||
_sendTypingStatusPackage();
|
||||
_typingStatus = true;
|
||||
}
|
||||
|
||||
if (_typingNotifyTimer == null || !_typingNotifyTimer!.isActive) {
|
||||
_typingNotifyTimer?.cancel();
|
||||
_typingNotifyTimer = Timer(const Duration(milliseconds: 1850), () {
|
||||
_typingStatus = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
|
||||
if (_box == null) return;
|
||||
await _box!.putAll({
|
||||
@ -167,8 +194,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
switch (message.type) {
|
||||
case 'messages.edit':
|
||||
if (message.relatedEventId != null) {
|
||||
final idx =
|
||||
messages.indexWhere((x) => x.id == message.relatedEventId);
|
||||
final idx = messages.indexWhere((x) => x.id == message.relatedEventId);
|
||||
if (idx != -1) {
|
||||
final newBody = message.body;
|
||||
newBody.remove('related_event');
|
||||
@ -207,8 +233,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
'algorithm': 'plain',
|
||||
if (quoteId != null) 'quote_event': quoteId,
|
||||
if (relatedId != null) 'related_event': relatedId,
|
||||
if (attachments != null && attachments.isNotEmpty)
|
||||
'attachments': attachments,
|
||||
if (attachments != null && attachments.isNotEmpty) 'attachments': attachments,
|
||||
};
|
||||
|
||||
// Mock the message locally
|
||||
@ -305,8 +330,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
|
||||
if (out == null) {
|
||||
try {
|
||||
final resp = await _sn.client
|
||||
.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
|
||||
final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
|
||||
out = SnChatMessage.fromJson(resp.data);
|
||||
_saveMessageToLocal([out]);
|
||||
} catch (_) {
|
||||
@ -341,9 +365,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
bool forceRemote = false,
|
||||
}) async {
|
||||
late List<SnChatMessage> out;
|
||||
if (_box != null &&
|
||||
(_box!.length >= take + offset || forceLocal) &&
|
||||
!forceRemote) {
|
||||
if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) {
|
||||
out = _box!.keys
|
||||
.toList()
|
||||
.cast<int>()
|
||||
@ -386,8 +408,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
quoteEvent: quoteEvent,
|
||||
attachments: attachments
|
||||
.where(
|
||||
(ele) =>
|
||||
out[i].body['attachments']?.contains(ele?.rid) ?? false,
|
||||
(ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@ -395,10 +416,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Preload sender accounts
|
||||
final accountId = out
|
||||
.where((ele) => ele.sender.accountId >= 0)
|
||||
.map((ele) => ele.sender.accountId)
|
||||
.toSet();
|
||||
final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet();
|
||||
await _ud.listAccount(accountId);
|
||||
|
||||
return out;
|
||||
|
@ -104,7 +104,7 @@ class PostWriteMedia {
|
||||
if (attachment != null) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
|
||||
if (width != null && height != null) {
|
||||
if (width != null && height != null && !kIsWeb) {
|
||||
return ResizeImage(
|
||||
provider,
|
||||
width: width,
|
||||
|
3
lib/providers/sticker.dart
Normal file
3
lib/providers/sticker.dart
Normal file
@ -0,0 +1,3 @@
|
||||
class StickerProvider {
|
||||
|
||||
}
|
@ -87,7 +87,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
try {
|
||||
final resp = await sn.client.request(
|
||||
widget.editingChannelAlias != null
|
||||
? '/cgi/im/channels/$scope/${widget.editingChannelAlias}'
|
||||
? '/cgi/im/channels/$scope/${_editingChannel!.id}'
|
||||
: '/cgi/im/channels/$scope',
|
||||
data: payload,
|
||||
options: Options(
|
||||
|
@ -17,6 +17,7 @@ import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/widgets/chat/call/call_prejoin.dart';
|
||||
import 'package:surface/widgets/chat/chat_message.dart';
|
||||
import 'package:surface/widgets/chat/chat_message_input.dart';
|
||||
import 'package:surface/widgets/chat/chat_typing_indicator.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
@ -280,11 +281,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
Expanded(
|
||||
child: InfiniteList(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 12,
|
||||
),
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
hasReachedMax: _messageController.isAllLoaded,
|
||||
itemCount: _messageController.messages.length,
|
||||
isLoading: _messageController.isLoading,
|
||||
@ -310,23 +307,20 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 480),
|
||||
child: ChatMessage(
|
||||
data: message,
|
||||
isMerged: canMerge,
|
||||
hasMerged: canMergePrevious,
|
||||
isPending: _messageController.unconfirmedMessages.contains(message.uuid),
|
||||
onReply: (value) {
|
||||
_inputGlobalKey.currentState?.setReply(value);
|
||||
},
|
||||
onEdit: (value) {
|
||||
_inputGlobalKey.currentState?.setEdit(value);
|
||||
},
|
||||
onDelete: (value) {
|
||||
_inputGlobalKey.currentState?.deleteMessage(value);
|
||||
},
|
||||
),
|
||||
child: ChatMessage(
|
||||
data: message,
|
||||
isMerged: canMerge,
|
||||
hasMerged: canMergePrevious,
|
||||
isPending: _messageController.unconfirmedMessages.contains(message.uuid),
|
||||
onReply: (value) {
|
||||
_inputGlobalKey.currentState?.setReply(value);
|
||||
},
|
||||
onEdit: (value) {
|
||||
_inputGlobalKey.currentState?.setEdit(value);
|
||||
},
|
||||
onDelete: (value) {
|
||||
_inputGlobalKey.currentState?.deleteMessage(value);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -335,11 +329,17 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
if (!_messageController.isPending)
|
||||
Material(
|
||||
elevation: 2,
|
||||
child: ChatMessageInput(
|
||||
key: _inputGlobalKey,
|
||||
otherMember: _otherMember,
|
||||
controller: _messageController,
|
||||
).padding(bottom: MediaQuery.of(context).padding.bottom),
|
||||
child: Column(
|
||||
children: [
|
||||
ChatTypingIndicator(controller: _messageController),
|
||||
ChatMessageInput(
|
||||
key: _inputGlobalKey,
|
||||
otherMember: _otherMember,
|
||||
controller: _messageController,
|
||||
),
|
||||
Gap(MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -153,9 +153,14 @@ class _HomeDashUpdateWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeDashSpecialDayWidget extends StatelessWidget {
|
||||
class _HomeDashSpecialDayWidget extends StatefulWidget {
|
||||
const _HomeDashSpecialDayWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashSpecialDayWidget> createState() => _HomeDashSpecialDayWidgetState();
|
||||
}
|
||||
|
||||
class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ua = context.watch<UserProvider>();
|
||||
@ -165,21 +170,20 @@ class _HomeDashSpecialDayWidget extends StatelessWidget {
|
||||
|
||||
if (days.isNotEmpty) {
|
||||
return Column(
|
||||
spacing: 8,
|
||||
children: days.map((ele) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
|
||||
title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
|
||||
subtitle: Text(
|
||||
DateFormat('y/M/d').format(DateTime.now().copyWith(
|
||||
month: kSpecialDays[ele]!.$1,
|
||||
day: kSpecialDays[ele]!.$2,
|
||||
)),
|
||||
),
|
||||
),
|
||||
).padding(bottom: 8);
|
||||
}).toList());
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
|
||||
title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
|
||||
subtitle: Text(
|
||||
DateFormat('y/M/d').format(DateTime.now().copyWith(
|
||||
month: kSpecialDays[ele]?.$1,
|
||||
day: kSpecialDays[ele]?.$2,
|
||||
)),
|
||||
),
|
||||
),
|
||||
).padding(bottom: 8);
|
||||
}).toList());
|
||||
}
|
||||
|
||||
final nextOne = dayz.getNextSpecialDay();
|
||||
@ -204,6 +208,9 @@ class _HomeDashSpecialDayWidget extends StatelessWidget {
|
||||
separatorType: SeparatorType.symbol,
|
||||
decoration: BoxDecoration(),
|
||||
padding: EdgeInsets.zero,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
|
@ -82,24 +82,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
List<int> markList = List.empty(growable: true);
|
||||
for (final element in _notifications) {
|
||||
if (element.id <= 0) continue;
|
||||
if (element.readAt != null) continue;
|
||||
markList.add(element.id);
|
||||
}
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.put('/cgi/id/notifications/read', data: {
|
||||
'messages': markList,
|
||||
});
|
||||
final resp = await sn.client.put('/cgi/id/notifications/read/all');
|
||||
_notifications.clear();
|
||||
_fetchNotifications();
|
||||
|
||||
if (!mounted) return;
|
||||
context.showSnackbar(
|
||||
'notificationMarkAllReadPrompt'.plural(markList.length),
|
||||
'notificationMarkAllReadPrompt'.plural(resp.data['count']),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
|
@ -88,9 +88,9 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
title: Text(_realm?.name ?? 'loading'.tr()),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(icon: const Icon(Symbols.home)),
|
||||
Tab(icon: const Icon(Symbols.group)),
|
||||
Tab(icon: const Icon(Symbols.settings)),
|
||||
Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -15,10 +15,10 @@ class AttachmentList extends StatefulWidget {
|
||||
final List<SnAttachment?> data;
|
||||
final bool bordered;
|
||||
final bool gridded;
|
||||
final bool noGrow;
|
||||
final BoxFit fit;
|
||||
final double? maxHeight;
|
||||
final double? minWidth;
|
||||
final double? maxWidth;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const AttachmentList({
|
||||
@ -26,10 +26,10 @@ class AttachmentList extends StatefulWidget {
|
||||
required this.data,
|
||||
this.bordered = false,
|
||||
this.gridded = false,
|
||||
this.noGrow = false,
|
||||
this.fit = BoxFit.cover,
|
||||
this.maxHeight,
|
||||
this.minWidth,
|
||||
this.maxWidth,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@ -106,76 +106,38 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
}
|
||||
|
||||
if (widget.gridded) {
|
||||
return Padding(
|
||||
padding: widget.padding ?? EdgeInsets.zero,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
top: borderSide,
|
||||
bottom: borderSide,
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: StaggeredGrid.count(
|
||||
crossAxisCount: math.min(widget.data.length, 2),
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
children: widget.data
|
||||
.mapIndexed(
|
||||
(idx, ele) => GestureDetector(
|
||||
child: Container(
|
||||
constraints: constraints,
|
||||
child: AttachmentItem(
|
||||
data: ele,
|
||||
heroTag: heroTags[idx],
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.data[idx]!.mediaType != SnMediaType.image) return;
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data: widget.data.where((ele) => ele != null).cast(),
|
||||
initialIndex: idx,
|
||||
heroTags: heroTags,
|
||||
),
|
||||
backgroundColor: Colors.black.withOpacity(0.7),
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
return Container(
|
||||
margin: widget.padding ?? EdgeInsets.zero,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
top: borderSide,
|
||||
bottom: borderSide,
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: (widget.data.firstOrNull?.data['ratio'] ?? 1).toDouble(),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxHeight: constraints.maxHeight),
|
||||
child: ScrollConfiguration(
|
||||
behavior: _AttachmentListScrollBehavior(),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.data.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return Container(
|
||||
constraints: constraints,
|
||||
child: AspectRatio(
|
||||
aspectRatio: (widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
|
||||
child: GestureDetector(
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: StaggeredGrid.count(
|
||||
crossAxisCount: math.min(widget.data.length, 2),
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
children: widget.data
|
||||
.mapIndexed(
|
||||
(idx, ele) => GestureDetector(
|
||||
child: Container(
|
||||
constraints: constraints,
|
||||
child: AttachmentItem(
|
||||
data: ele,
|
||||
heroTag: heroTags[idx],
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.data[idx]?.mediaType != SnMediaType.image) return;
|
||||
if (widget.data[idx]!.mediaType != SnMediaType.image) return;
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data:
|
||||
widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(),
|
||||
data: widget.data.where((ele) => ele != null).cast(),
|
||||
initialIndex: idx,
|
||||
heroTags: heroTags,
|
||||
),
|
||||
@ -183,44 +145,76 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
top: borderSide,
|
||||
bottom: borderSide,
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxHeight: constraints.maxHeight),
|
||||
child: ScrollConfiguration(
|
||||
behavior: _AttachmentListScrollBehavior(),
|
||||
child: ListView.separated(
|
||||
padding: widget.padding,
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.data.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return Container(
|
||||
constraints: constraints.copyWith(maxWidth: widget.maxWidth),
|
||||
child: AspectRatio(
|
||||
aspectRatio: (widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.data[idx]?.mediaType != SnMediaType.image) return;
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data: widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(),
|
||||
initialIndex: idx,
|
||||
heroTags: heroTags,
|
||||
),
|
||||
backgroundColor: Colors.black.withOpacity(0.7),
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
top: borderSide,
|
||||
bottom: borderSide,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: AttachmentItem(
|
||||
data: widget.data[idx],
|
||||
heroTag: heroTags[idx],
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: AttachmentItem(
|
||||
data: widget.data[idx],
|
||||
heroTag: heroTags[idx],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Chip(
|
||||
label: Text('${idx + 1}/${widget.data.length}'),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Chip(
|
||||
label: Text('${idx + 1}/${widget.data.length}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
padding: widget.padding,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
scrollDirection: Axis.horizontal,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
scrollDirection: Axis.horizontal,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -231,7 +231,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: AccountImage(
|
||||
content: account!.avatar,
|
||||
content: account?.avatar,
|
||||
radius: 19,
|
||||
),
|
||||
),
|
||||
@ -246,7 +246,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
account.nick,
|
||||
account?.nick ?? 'unknown'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
|
@ -24,6 +24,7 @@ class ChatMessage extends StatelessWidget {
|
||||
final Function(SnChatMessage)? onReply;
|
||||
final Function(SnChatMessage)? onEdit;
|
||||
final Function(SnChatMessage)? onDelete;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const ChatMessage({
|
||||
super.key,
|
||||
@ -35,6 +36,7 @@ class ChatMessage extends StatelessWidget {
|
||||
this.onReply,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.padding = const EdgeInsets.only(left: 12, right: 12),
|
||||
});
|
||||
|
||||
@override
|
||||
@ -87,84 +89,89 @@ class ChatMessage extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMerged && !isCompact)
|
||||
AccountImage(
|
||||
content: user?.avatar,
|
||||
)
|
||||
else if (isMerged)
|
||||
const Gap(40),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMerged)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (isCompact)
|
||||
AccountImage(
|
||||
content: user?.avatar,
|
||||
radius: 12,
|
||||
).padding(right: 8),
|
||||
Text(
|
||||
(data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown',
|
||||
).bold(),
|
||||
const Gap(8),
|
||||
Text(
|
||||
dateFormatter.format(data.createdAt.toLocal()),
|
||||
).fontSize(13),
|
||||
],
|
||||
),
|
||||
if (isCompact) const Gap(4),
|
||||
if (data.preload?.quoteEvent != null)
|
||||
StyledWidget(Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
Padding(
|
||||
padding: isCompact ? EdgeInsets.zero : padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMerged && !isCompact)
|
||||
AccountImage(
|
||||
content: user?.avatar,
|
||||
)
|
||||
else if (isMerged)
|
||||
const Gap(40),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMerged)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (isCompact)
|
||||
AccountImage(
|
||||
content: user?.avatar,
|
||||
radius: 12,
|
||||
).padding(right: 8),
|
||||
Text(
|
||||
(data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown',
|
||||
).bold(),
|
||||
const Gap(8),
|
||||
Text(
|
||||
dateFormatter.format(data.createdAt.toLocal()),
|
||||
).fontSize(13),
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 4,
|
||||
right: 4,
|
||||
top: 8,
|
||||
bottom: 6,
|
||||
),
|
||||
child: ChatMessage(
|
||||
data: data.preload!.quoteEvent!,
|
||||
isCompact: true,
|
||||
onReply: onReply,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
),
|
||||
)).padding(bottom: 4, top: 4),
|
||||
switch (data.type) {
|
||||
'messages.new' => _ChatMessageText(data: data),
|
||||
_ => _ChatMessageSystemNotify(data: data),
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
).opacity(isPending ? 0.5 : 1),
|
||||
if (isCompact) const Gap(8),
|
||||
if (data.preload?.quoteEvent != null)
|
||||
StyledWidget(Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 4,
|
||||
right: 4,
|
||||
top: 8,
|
||||
bottom: 6,
|
||||
),
|
||||
child: ChatMessage(
|
||||
data: data.preload!.quoteEvent!,
|
||||
isCompact: true,
|
||||
onReply: onReply,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
),
|
||||
)).padding(bottom: 4, top: 4),
|
||||
switch (data.type) {
|
||||
'messages.new' => _ChatMessageText(data: data),
|
||||
_ => _ChatMessageSystemNotify(data: data),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
).opacity(isPending ? 0.5 : 1),
|
||||
),
|
||||
if (data.body['text'] != null && data.type == 'messages.new' && (data.body['text']?.isNotEmpty ?? false))
|
||||
LinkPreviewWidget(text: data.body['text']!),
|
||||
if (data.preload?.attachments?.isNotEmpty ?? false)
|
||||
AttachmentList(
|
||||
data: data.preload!.attachments!,
|
||||
bordered: true,
|
||||
gridded: true,
|
||||
noGrow: true,
|
||||
maxHeight: 560,
|
||||
maxWidth: 480,
|
||||
minWidth: 480,
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
padding: padding.copyWith(top: 8),
|
||||
),
|
||||
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6),
|
||||
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -11,7 +11,6 @@ import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/markdown_content.dart';
|
||||
import 'package:surface/widgets/post/post_media_pending_list.dart';
|
||||
|
||||
class ChatMessageInput extends StatefulWidget {
|
||||
@ -33,6 +32,16 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
final TextEditingController _contentController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_contentController.addListener(() {
|
||||
if (_contentController.text.isNotEmpty) {
|
||||
widget.controller.pingTypingStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setReply(SnChatMessage? value) {
|
||||
setState(() => _replyingMessage = value);
|
||||
}
|
||||
@ -165,7 +174,6 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
? Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
@ -205,7 +213,6 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
? Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
|
53
lib/widgets/chat/chat_typing_indicator.dart
Normal file
53
lib/widgets/chat/chat_typing_indicator.dart
Normal file
@ -0,0 +1,53 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/chat_message_controller.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
|
||||
class ChatTypingIndicator extends StatelessWidget {
|
||||
final ChatMessageController controller;
|
||||
|
||||
const ChatTypingIndicator({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
|
||||
return StyledWidget(controller.typingMembers.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.more_horiz, weight: 600, size: 20),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'messageTyping'.plural(controller.typingMembers.length, args: [
|
||||
controller.typingMembers
|
||||
.map((ele) => (ele.nick?.isNotEmpty ?? false)
|
||||
? ele.nick!
|
||||
: ud.getAccountFromCache(ele.accountId)?.name ?? 'unknown')
|
||||
.join(', '),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.height(controller.typingMembers.isNotEmpty ? 38 : 0, animate: true)
|
||||
.animate(
|
||||
const Duration(milliseconds: 300),
|
||||
Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
}
|
@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2.2.1+43
|
||||
version: 2.2.1+44
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.4
|
||||
|
Reference in New Issue
Block a user