Compare commits

...

6 Commits

Author SHA1 Message Date
cb05ff2e9e 🚀 Launch 2.2.1+44 2025-01-01 19:38:57 +08:00
f614da7918 💄 Optimize attachment list 2025-01-01 17:57:41 +08:00
a3c8dafff9 User typing status
🐛 Bug fixes
2025-01-01 16:45:37 +08:00
fa978a7cd1 🐛 Fix notification mark all as read issue 2025-01-01 11:50:06 +08:00
aaa0a562b4 💄 Fix transparent icon color issue 2025-01-01 01:48:35 +08:00
590a4ce2a6 🐛 Bug fixes on chat message rendering 2025-01-01 01:11:35 +08:00
17 changed files with 354 additions and 267 deletions

View File

@ -281,6 +281,10 @@
"one": "{} attachment", "one": "{} attachment",
"other": "{} attachments" "other": "{} attachments"
}, },
"messageTyping": {
"one": "{} is typing...",
"other": "{} are typing..."
},
"fieldAttachmentRandomId": "Random ID", "fieldAttachmentRandomId": "Random ID",
"fieldAttachmentAlt": "Alternative text", "fieldAttachmentAlt": "Alternative text",
"addAttachmentFromAlbum": "Add from album", "addAttachmentFromAlbum": "Add from album",

View File

@ -279,6 +279,10 @@
"one": "{} 个附件", "one": "{} 个附件",
"other": "{} 个附件" "other": "{} 个附件"
}, },
"messageTyping": {
"one": "{} 正在输入",
"other": "{} 正在输入"
},
"fieldAttachmentRandomId": "访问 ID", "fieldAttachmentRandomId": "访问 ID",
"fieldAttachmentAlt": "概述文字", "fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "从相册中添加附件", "addAttachmentFromAlbum": "从相册中添加附件",

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:collection/collection.dart'; 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/user_directory.dart';
import 'package:surface/providers/websocket.dart'; import 'package:surface/providers/websocket.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/types/websocket.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class ChatMessageController extends ChangeNotifier { class ChatMessageController extends ChangeNotifier {
@ -36,8 +38,7 @@ class ChatMessageController extends ChangeNotifier {
int? messageTotal; int? messageTotal;
bool get isAllLoaded => bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!;
messageTotal != null && messages.length >= messageTotal!;
String? _boxKey; String? _boxKey;
SnChannel? channel; SnChannel? channel;
@ -50,8 +51,10 @@ class ChatMessageController extends ChangeNotifier {
/// Stored as a list of nonce to provide the loading state /// Stored as a list of nonce to provide the loading state
final List<String> unconfirmedMessages = List.empty(growable: true); final List<String> unconfirmedMessages = List.empty(growable: true);
Box<SnChatMessage>? get _box => Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
(_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 { Future<void> initialize(SnChannel chan) async {
channel = chan; channel = chan;
@ -78,22 +81,17 @@ class ChatMessageController extends ChangeNotifier {
if (event.payload?['channel_id'] != channel?.id) break; if (event.payload?['channel_id'] != channel?.id) break;
final member = SnChannelMember.fromJson(event.payload!['member']); final member = SnChannelMember.fromJson(event.payload!['member']);
if (member.id == profile?.id) break; if (member.id == profile?.id) break;
// TODO impl typing users if (!typingMembers.any((x) => x.id == member.id)) {
// if (!_typingUsers.any((x) => x.id == member.id)) { typingMembers.add(member);
// setState(() { print('Typing member: ${typingMembers.map((ele) => member.id)}');
// _typingUsers.add(member); notifyListeners();
// }); }
// } typingInactiveTimer[member.id]?.cancel();
// _typingInactiveTimer[member.id]?.cancel(); typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () {
// _typingInactiveTimer[member.id] = Timer( typingMembers.removeWhere((x) => x.id == member.id);
// const Duration(seconds: 3), typingInactiveTimer.remove(member.id);
// () { notifyListeners();
// setState(() { });
// _typingUsers.removeWhere((x) => x.id == member.id);
// _typingInactiveTimer.remove(member.id);
// });
// },
// );
} }
}); });
@ -101,6 +99,35 @@ class ChatMessageController extends ChangeNotifier {
notifyListeners(); 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 { Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
if (_box == null) return; if (_box == null) return;
await _box!.putAll({ await _box!.putAll({
@ -167,8 +194,7 @@ class ChatMessageController extends ChangeNotifier {
switch (message.type) { switch (message.type) {
case 'messages.edit': case 'messages.edit':
if (message.relatedEventId != null) { if (message.relatedEventId != null) {
final idx = final idx = messages.indexWhere((x) => x.id == message.relatedEventId);
messages.indexWhere((x) => x.id == message.relatedEventId);
if (idx != -1) { if (idx != -1) {
final newBody = message.body; final newBody = message.body;
newBody.remove('related_event'); newBody.remove('related_event');
@ -207,8 +233,7 @@ class ChatMessageController extends ChangeNotifier {
'algorithm': 'plain', 'algorithm': 'plain',
if (quoteId != null) 'quote_event': quoteId, if (quoteId != null) 'quote_event': quoteId,
if (relatedId != null) 'related_event': relatedId, if (relatedId != null) 'related_event': relatedId,
if (attachments != null && attachments.isNotEmpty) if (attachments != null && attachments.isNotEmpty) 'attachments': attachments,
'attachments': attachments,
}; };
// Mock the message locally // Mock the message locally
@ -305,8 +330,7 @@ class ChatMessageController extends ChangeNotifier {
if (out == null) { if (out == null) {
try { try {
final resp = await _sn.client final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
out = SnChatMessage.fromJson(resp.data); out = SnChatMessage.fromJson(resp.data);
_saveMessageToLocal([out]); _saveMessageToLocal([out]);
} catch (_) { } catch (_) {
@ -341,9 +365,7 @@ class ChatMessageController extends ChangeNotifier {
bool forceRemote = false, bool forceRemote = false,
}) async { }) async {
late List<SnChatMessage> out; late List<SnChatMessage> out;
if (_box != null && if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) {
(_box!.length >= take + offset || forceLocal) &&
!forceRemote) {
out = _box!.keys out = _box!.keys
.toList() .toList()
.cast<int>() .cast<int>()
@ -386,8 +408,7 @@ class ChatMessageController extends ChangeNotifier {
quoteEvent: quoteEvent, quoteEvent: quoteEvent,
attachments: attachments attachments: attachments
.where( .where(
(ele) => (ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false,
out[i].body['attachments']?.contains(ele?.rid) ?? false,
) )
.toList(), .toList(),
), ),
@ -395,10 +416,7 @@ class ChatMessageController extends ChangeNotifier {
} }
// Preload sender accounts // Preload sender accounts
final accountId = out final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet();
.where((ele) => ele.sender.accountId >= 0)
.map((ele) => ele.sender.accountId)
.toSet();
await _ud.listAccount(accountId); await _ud.listAccount(accountId);
return out; return out;

View File

@ -104,7 +104,7 @@ class PostWriteMedia {
if (attachment != null) { if (attachment != null) {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid)); final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
if (width != null && height != null) { if (width != null && height != null && !kIsWeb) {
return ResizeImage( return ResizeImage(
provider, provider,
width: width, width: width,

View File

@ -0,0 +1,3 @@
class StickerProvider {
}

View File

@ -87,7 +87,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
try { try {
final resp = await sn.client.request( final resp = await sn.client.request(
widget.editingChannelAlias != null widget.editingChannelAlias != null
? '/cgi/im/channels/$scope/${widget.editingChannelAlias}' ? '/cgi/im/channels/$scope/${_editingChannel!.id}'
: '/cgi/im/channels/$scope', : '/cgi/im/channels/$scope',
data: payload, data: payload,
options: Options( options: Options(

View File

@ -17,6 +17,7 @@ import 'package:surface/types/chat.dart';
import 'package:surface/widgets/chat/call/call_prejoin.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.dart';
import 'package:surface/widgets/chat/chat_message_input.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/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -280,11 +281,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
Expanded( Expanded(
child: InfiniteList( child: InfiniteList(
reverse: true, reverse: true,
padding: const EdgeInsets.only( padding: const EdgeInsets.only(top: 12),
left: 12,
right: 12,
top: 12,
),
hasReachedMax: _messageController.isAllLoaded, hasReachedMax: _messageController.isAllLoaded,
itemCount: _messageController.messages.length, itemCount: _messageController.messages.length,
isLoading: _messageController.isLoading, isLoading: _messageController.isLoading,
@ -310,23 +307,20 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Container( child: ChatMessage(
constraints: BoxConstraints(maxWidth: 480), data: message,
child: ChatMessage( isMerged: canMerge,
data: message, hasMerged: canMergePrevious,
isMerged: canMerge, isPending: _messageController.unconfirmedMessages.contains(message.uuid),
hasMerged: canMergePrevious, onReply: (value) {
isPending: _messageController.unconfirmedMessages.contains(message.uuid), _inputGlobalKey.currentState?.setReply(value);
onReply: (value) { },
_inputGlobalKey.currentState?.setReply(value); onEdit: (value) {
}, _inputGlobalKey.currentState?.setEdit(value);
onEdit: (value) { },
_inputGlobalKey.currentState?.setEdit(value); onDelete: (value) {
}, _inputGlobalKey.currentState?.deleteMessage(value);
onDelete: (value) { },
_inputGlobalKey.currentState?.deleteMessage(value);
},
),
), ),
); );
}, },
@ -335,11 +329,17 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
if (!_messageController.isPending) if (!_messageController.isPending)
Material( Material(
elevation: 2, elevation: 2,
child: ChatMessageInput( child: Column(
key: _inputGlobalKey, children: [
otherMember: _otherMember, ChatTypingIndicator(controller: _messageController),
controller: _messageController, ChatMessageInput(
).padding(bottom: MediaQuery.of(context).padding.bottom), key: _inputGlobalKey,
otherMember: _otherMember,
controller: _messageController,
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
), ),
], ],
); );

View File

@ -153,9 +153,14 @@ class _HomeDashUpdateWidget extends StatelessWidget {
} }
} }
class _HomeDashSpecialDayWidget extends StatelessWidget { class _HomeDashSpecialDayWidget extends StatefulWidget {
const _HomeDashSpecialDayWidget(); const _HomeDashSpecialDayWidget();
@override
State<_HomeDashSpecialDayWidget> createState() => _HomeDashSpecialDayWidgetState();
}
class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>(); final ua = context.watch<UserProvider>();
@ -165,21 +170,20 @@ class _HomeDashSpecialDayWidget extends StatelessWidget {
if (days.isNotEmpty) { if (days.isNotEmpty) {
return Column( return Column(
spacing: 8,
children: days.map((ele) { children: days.map((ele) {
return Card( return Card(
child: ListTile( child: ListTile(
leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24), leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']), title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
subtitle: Text( subtitle: Text(
DateFormat('y/M/d').format(DateTime.now().copyWith( DateFormat('y/M/d').format(DateTime.now().copyWith(
month: kSpecialDays[ele]!.$1, month: kSpecialDays[ele]?.$1,
day: kSpecialDays[ele]!.$2, day: kSpecialDays[ele]?.$2,
)), )),
), ),
), ),
).padding(bottom: 8); ).padding(bottom: 8);
}).toList()); }).toList());
} }
final nextOne = dayz.getNextSpecialDay(); final nextOne = dayz.getNextSpecialDay();
@ -204,6 +208,9 @@ class _HomeDashSpecialDayWidget extends StatelessWidget {
separatorType: SeparatorType.symbol, separatorType: SeparatorType.symbol,
decoration: BoxDecoration(), decoration: BoxDecoration(),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onDone: () {
setState(() {});
},
), ),
const Gap(12), const Gap(12),
Expanded( Expanded(

View File

@ -82,24 +82,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
if (!mounted) return; if (!mounted) return;
setState(() => _isSubmitting = true); 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 { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/notifications/read', data: { final resp = await sn.client.put('/cgi/id/notifications/read/all');
'messages': markList,
});
_notifications.clear(); _notifications.clear();
_fetchNotifications(); _fetchNotifications();
if (!mounted) return; if (!mounted) return;
context.showSnackbar( context.showSnackbar(
'notificationMarkAllReadPrompt'.plural(markList.length), 'notificationMarkAllReadPrompt'.plural(resp.data['count']),
); );
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;

View File

@ -88,9 +88,9 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
title: Text(_realm?.name ?? 'loading'.tr()), title: Text(_realm?.name ?? 'loading'.tr()),
bottom: TabBar( bottom: TabBar(
tabs: [ tabs: [
Tab(icon: const Icon(Symbols.home)), Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)),
Tab(icon: const Icon(Symbols.group)), Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)),
Tab(icon: const Icon(Symbols.settings)), Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)),
], ],
), ),
), ),

View File

@ -15,10 +15,10 @@ class AttachmentList extends StatefulWidget {
final List<SnAttachment?> data; final List<SnAttachment?> data;
final bool bordered; final bool bordered;
final bool gridded; final bool gridded;
final bool noGrow;
final BoxFit fit; final BoxFit fit;
final double? maxHeight; final double? maxHeight;
final double? minWidth; final double? minWidth;
final double? maxWidth;
final EdgeInsets? padding; final EdgeInsets? padding;
const AttachmentList({ const AttachmentList({
@ -26,10 +26,10 @@ class AttachmentList extends StatefulWidget {
required this.data, required this.data,
this.bordered = false, this.bordered = false,
this.gridded = false, this.gridded = false,
this.noGrow = false,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.maxHeight, this.maxHeight,
this.minWidth, this.minWidth,
this.maxWidth,
this.padding, this.padding,
}); });
@ -106,76 +106,38 @@ class _AttachmentListState extends State<AttachmentList> {
} }
if (widget.gridded) { if (widget.gridded) {
return Padding( return Container(
padding: widget.padding ?? EdgeInsets.zero, margin: widget.padding ?? EdgeInsets.zero,
child: Container( decoration: BoxDecoration(
decoration: BoxDecoration( color: backgroundColor,
color: backgroundColor, border: Border(
border: Border( top: borderSide,
top: borderSide, bottom: 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(),
),
), ),
borderRadius: AttachmentList.kDefaultRadius,
), ),
); child: ClipRRect(
} borderRadius: AttachmentList.kDefaultRadius,
child: StaggeredGrid.count(
return AspectRatio( crossAxisCount: math.min(widget.data.length, 2),
aspectRatio: (widget.data.firstOrNull?.data['ratio'] ?? 1).toDouble(), crossAxisSpacing: 4,
child: Container( mainAxisSpacing: 4,
constraints: BoxConstraints(maxHeight: constraints.maxHeight), children: widget.data
child: ScrollConfiguration( .mapIndexed(
behavior: _AttachmentListScrollBehavior(), (idx, ele) => GestureDetector(
child: ListView.separated( child: Container(
shrinkWrap: true, constraints: constraints,
itemCount: widget.data.length, child: AttachmentItem(
itemBuilder: (context, idx) { data: ele,
return Container( heroTag: heroTags[idx],
constraints: constraints, fit: BoxFit.cover,
child: AspectRatio( ),
aspectRatio: (widget.data[idx]?.data['ratio'] ?? 1).toDouble(), ),
child: GestureDetector(
onTap: () { onTap: () {
if (widget.data[idx]?.mediaType != SnMediaType.image) return; if (widget.data[idx]!.mediaType != SnMediaType.image) return;
context.pushTransparentRoute( context.pushTransparentRoute(
AttachmentZoomView( AttachmentZoomView(
data: data: widget.data.where((ele) => ele != null).cast(),
widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(),
initialIndex: idx, initialIndex: idx,
heroTags: heroTags, heroTags: heroTags,
), ),
@ -183,44 +145,76 @@ class _AttachmentListState extends State<AttachmentList> {
rootNavigator: true, rootNavigator: true,
); );
}, },
child: Stack( ),
fit: StackFit.expand, )
children: [ .toList(),
Container( ),
decoration: BoxDecoration( ),
color: backgroundColor, );
border: Border( }
top: borderSide,
bottom: borderSide, return Container(
), constraints: BoxConstraints(maxHeight: constraints.maxHeight),
borderRadius: AttachmentList.kDefaultRadius, 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,
borderRadius: AttachmentList.kDefaultRadius, ),
child: AttachmentItem( child: ClipRRect(
data: widget.data[idx], borderRadius: AttachmentList.kDefaultRadius,
heroTag: heroTags[idx], child: AttachmentItem(
), data: widget.data[idx],
heroTag: heroTags[idx],
), ),
), ),
Positioned( ),
right: 8, Positioned(
bottom: 8, right: 8,
child: Chip( bottom: 8,
label: Text('${idx + 1}/${widget.data.length}'), child: Chip(
), label: Text('${idx + 1}/${widget.data.length}'),
), ),
], ),
), ],
), ),
), ),
); ),
}, );
separatorBuilder: (context, index) => const Gap(8), },
padding: widget.padding, separatorBuilder: (context, index) => const Gap(8),
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
),
), ),
), ),
); );

View File

@ -231,7 +231,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
children: [ children: [
IgnorePointer( IgnorePointer(
child: AccountImage( child: AccountImage(
content: account!.avatar, content: account?.avatar,
radius: 19, radius: 19,
), ),
), ),
@ -246,7 +246,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
Text( Text(
account.nick, account?.nick ?? 'unknown'.tr(),
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
], ],

View File

@ -1,6 +1,5 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';

View File

@ -24,6 +24,7 @@ class ChatMessage extends StatelessWidget {
final Function(SnChatMessage)? onReply; final Function(SnChatMessage)? onReply;
final Function(SnChatMessage)? onEdit; final Function(SnChatMessage)? onEdit;
final Function(SnChatMessage)? onDelete; final Function(SnChatMessage)? onDelete;
final EdgeInsets padding;
const ChatMessage({ const ChatMessage({
super.key, super.key,
@ -35,6 +36,7 @@ class ChatMessage extends StatelessWidget {
this.onReply, this.onReply,
this.onEdit, this.onEdit,
this.onDelete, this.onDelete,
this.padding = const EdgeInsets.only(left: 12, right: 12),
}); });
@override @override
@ -87,84 +89,89 @@ class ChatMessage extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: isCompact ? EdgeInsets.zero : padding,
children: [ child: Row(
if (!isMerged && !isCompact) crossAxisAlignment: CrossAxisAlignment.start,
AccountImage( children: [
content: user?.avatar, if (!isMerged && !isCompact)
) AccountImage(
else if (isMerged) content: user?.avatar,
const Gap(40), )
const Gap(8), else if (isMerged)
Expanded( const Gap(40),
child: Column( const Gap(8),
crossAxisAlignment: CrossAxisAlignment.start, Expanded(
children: [ child: Container(
if (!isMerged) constraints: BoxConstraints(maxWidth: 480),
Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (isCompact) if (!isMerged)
AccountImage( Row(
content: user?.avatar, crossAxisAlignment: CrossAxisAlignment.center,
radius: 12, children: [
).padding(right: 8), if (isCompact)
Text( AccountImage(
(data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown', content: user?.avatar,
).bold(), radius: 12,
const Gap(8), ).padding(right: 8),
Text( Text(
dateFormatter.format(data.createdAt.toLocal()), (data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown',
).fontSize(13), ).bold(),
], const Gap(8),
), Text(
if (isCompact) const Gap(4), dateFormatter.format(data.createdAt.toLocal()),
if (data.preload?.quoteEvent != null) ).fontSize(13),
StyledWidget(Container( ],
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
), ),
), if (isCompact) const Gap(8),
padding: const EdgeInsets.only( if (data.preload?.quoteEvent != null)
left: 4, StyledWidget(Container(
right: 4, decoration: BoxDecoration(
top: 8, borderRadius: const BorderRadius.all(Radius.circular(8)),
bottom: 6, border: Border.all(
), color: Theme.of(context).dividerColor,
child: ChatMessage( width: 1,
data: data.preload!.quoteEvent!, ),
isCompact: true, ),
onReply: onReply, padding: const EdgeInsets.only(
onEdit: onEdit, left: 4,
onDelete: onDelete, right: 4,
), top: 8,
)).padding(bottom: 4, top: 4), bottom: 6,
switch (data.type) { ),
'messages.new' => _ChatMessageText(data: data), child: ChatMessage(
_ => _ChatMessageSystemNotify(data: data), data: data.preload!.quoteEvent!,
}, isCompact: true,
], onReply: onReply,
), onEdit: onEdit,
) onDelete: onDelete,
], ),
).opacity(isPending ? 0.5 : 1), )).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)) if (data.body['text'] != null && data.type == 'messages.new' && (data.body['text']?.isNotEmpty ?? false))
LinkPreviewWidget(text: data.body['text']!), LinkPreviewWidget(text: data.body['text']!),
if (data.preload?.attachments?.isNotEmpty ?? false) if (data.preload?.attachments?.isNotEmpty ?? false)
AttachmentList( AttachmentList(
data: data.preload!.attachments!, data: data.preload!.attachments!,
bordered: true, bordered: true,
gridded: true,
noGrow: true,
maxHeight: 560, maxHeight: 560,
maxWidth: 480,
minWidth: 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),
], ],
), ),
), ),

View File

@ -11,7 +11,6 @@ import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart'; import 'package:surface/widgets/post/post_media_pending_list.dart';
class ChatMessageInput extends StatefulWidget { class ChatMessageInput extends StatefulWidget {
@ -33,6 +32,16 @@ class ChatMessageInputState extends State<ChatMessageInput> {
final TextEditingController _contentController = TextEditingController(); final TextEditingController _contentController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_contentController.addListener(() {
if (_contentController.text.isNotEmpty) {
widget.controller.pingTypingStatus();
}
});
}
void setReply(SnChatMessage? value) { void setReply(SnChatMessage? value) {
setState(() => _replyingMessage = value); setState(() => _replyingMessage = value);
} }
@ -165,7 +174,6 @@ class ChatMessageInputState extends State<ChatMessageInput> {
? Container( ? Container(
padding: const EdgeInsets.only(left: 16, right: 16), padding: const EdgeInsets.only(left: 16, right: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
@ -205,7 +213,6 @@ class ChatMessageInputState extends State<ChatMessageInput> {
? Container( ? Container(
padding: const EdgeInsets.only(left: 16, right: 16), padding: const EdgeInsets.only(left: 16, right: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,

View 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,
);
}
}

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.5.4 sdk: ^3.5.4