Compare commits

...

5 Commits

Author SHA1 Message Date
cc9081b011 🐛 Fix image loading issue on web 2024-08-24 11:47:40 +08:00
14e8f7b775 🐛 Bug fixes and optimization 2024-08-23 23:16:41 +08:00
a70e6c7118 Typing indicator 2024-08-23 22:43:04 +08:00
48ca885a2c 🚀 Launch 1.2.1+22 2024-08-22 01:06:10 +08:00
09cb340a9d 🐛 Fix personalize page issue 2024-08-22 00:42:17 +08:00
14 changed files with 267 additions and 66 deletions

View File

@ -1,22 +1,26 @@
class NetworkPackage { class NetworkPackage {
String method; String method;
String? endpoint;
String? message; String? message;
Map<String, dynamic>? payload; Map<String, dynamic>? payload;
NetworkPackage({ NetworkPackage({
required this.method, required this.method,
this.endpoint,
this.message, this.message,
this.payload, this.payload,
}); });
factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage( factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage(
method: json['w'], method: json['w'],
endpoint: json['e'],
message: json['m'], message: json['m'],
payload: json['p'], payload: json['p'],
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'w': method, 'w': method,
'e': endpoint,
'm': message, 'm': message,
'p': payload, 'p': payload,
}; };

View File

@ -88,6 +88,7 @@ class WebSocketProvider extends GetxController {
websocket?.stream.listen( websocket?.stream.listen(
(event) { (event) {
final packet = NetworkPackage.fromJson(jsonDecode(event)); final packet = NetworkPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet); stream.sink.add(packet);
}, },
onDone: () { onDone: () {
@ -148,7 +149,7 @@ class WebSocketProvider extends GetxController {
'device_token': token, 'device_token': token,
'device_id': deviceUuid, 'device_id': deviceUuid,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200 && resp.statusCode != 400) {
throw RequestException(resp); throw RequestException(resp);
} }
} }

View File

@ -30,8 +30,8 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
final _birthdayController = TextEditingController(); final _birthdayController = TextEditingController();
int? _avatar; String? _avatar;
int? _banner; String? _banner;
DateTime? _birthday; DateTime? _birthday;
bool _isBusy = false; bool _isBusy = false;
@ -129,7 +129,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final resp = await client.put( final resp = await client.put(
'/users/me/$position', '/users/me/$position',
{'attachment': attachResult.id}, {'attachment': attachResult.rid},
); );
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
_syncWidget(); _syncWidget();

View File

@ -24,6 +24,7 @@ import 'package:solian/widgets/channel/channel_call_indicator.dart';
import 'package:solian/widgets/chat/call/chat_call_action.dart'; import 'package:solian/widgets/chat/call/chat_call_action.dart';
import 'package:solian/widgets/chat/chat_event_list.dart'; import 'package:solian/widgets/chat/chat_event_list.dart';
import 'package:solian/widgets/chat/chat_message_input.dart'; import 'package:solian/widgets/chat/chat_message_input.dart';
import 'package:solian/widgets/chat/chat_typing_indicator.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
@ -103,12 +104,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
final List<ChannelMember> _typingUsers = List.empty(growable: true);
final Map<int, Timer> _typingInactiveTimer = {};
void _listenMessages() { void _listenMessages() {
final WebSocketProvider provider = Get.find(); final WebSocketProvider ws = Get.find();
_subscription = provider.stream.stream.listen((event) { _subscription = ws.stream.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'events.new': case 'events.new':
final payload = Event.fromJson(event.payload!); final payload = Event.fromJson(event.payload!);
final typingIdx =
_typingUsers.indexWhere((x) => x.id == payload.senderId);
if (typingIdx != -1) _typingUsers.removeAt(typingIdx);
_chatController.receiveEvent(payload); _chatController.receiveEvent(payload);
break; break;
case 'calls.new': case 'calls.new':
@ -123,6 +130,25 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
setState(() => _ongoingCall = null); setState(() => _ongoingCall = null);
} }
break; break;
case 'status.typing':
if (event.payload?['channel_id'] != _channel!.id) break;
final member = ChannelMember.fromJson(event.payload!['member']);
if (member.id == _channelProfile!.id) break;
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);
});
},
);
} }
}); });
} }
@ -280,7 +306,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: SafeArea( child: SafeArea(
child: ChatMessageInput( child: Column(
children: [
ChatTypingIndicator(users: _typingUsers),
ChatMessageInput(
edit: _messageToEditing, edit: _messageToEditing,
reply: _messageToReplying, reply: _messageToReplying,
realm: widget.realm, realm: widget.realm,
@ -298,6 +327,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
}); });
}, },
), ),
],
),
), ),
), ),
), ),
@ -329,6 +360,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
@override @override
void dispose() { void dispose() {
for (var timer in _typingInactiveTimer.values) {
timer.cancel();
}
_subscription?.cancel(); _subscription?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();

View File

@ -385,4 +385,5 @@ const i18nEnglish = {
'unknown': 'Unknown', 'unknown': 'Unknown',
'collapse': 'Collapse', 'collapse': 'Collapse',
'expand': 'Expand', 'expand': 'Expand',
'typingMessage': '@user are typing...',
}; };

View File

@ -355,4 +355,5 @@ const i18nSimplifiedChinese = {
'unknown': '未知', 'unknown': '未知',
'collapse': '折叠', 'collapse': '折叠',
'expand': '展开', 'expand': '展开',
'typingMessage': '@user 正在输入中…',
}; };

View File

@ -147,17 +147,21 @@ class _AttachmentItemImage extends StatelessWidget {
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Center( child: Column(
child: const Icon(Icons.close, size: 32) mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.close, size: 32)
.animate(onPlay: (e) => e.repeat(reverse: true)) .animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms), .fade(duration: 500.ms),
Text(error.toString()),
],
), ),
); );
}, },
) )
else else
Image.network( Image.network(
ServiceFinder.buildUrl('files', '/attachments/${item.id}'), ServiceFinder.buildUrl('files', '/attachments/${item.rid}'),
fit: fit, fit: fit,
loadingBuilder: (BuildContext context, Widget child, loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) { ImageChunkEvent? loadingProgress) {
@ -174,10 +178,14 @@ class _AttachmentItemImage extends StatelessWidget {
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Center( child: Column(
child: const Icon(Icons.close, size: 32) mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.close, size: 32)
.animate(onPlay: (e) => e.repeat(reverse: true)) .animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms), .fade(duration: 500.ms),
Text(error.toString()),
],
), ),
); );
}, },

View File

@ -132,6 +132,9 @@ class _AttachmentListState extends State<AttachmentList> {
_getMetadataList(); _getMetadataList();
} }
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.attachmentsId.isEmpty) { if (widget.attachmentsId.isEmpty) {
@ -139,12 +142,24 @@ class _AttachmentListState extends State<AttachmentList> {
} }
if (_isLoading) { if (_isLoading) {
return Container( return Row(
decoration: BoxDecoration( children: [
color: Theme.of(context).colorScheme.surfaceContainerHigh, Icon(
Icons.file_copy,
size: 12,
color: _unFocusColor,
).paddingOnly(right: 5),
Text(
'attachmentHint'.trParams(
{'count': widget.attachmentsId.length.toString()},
), ),
child: const LinearProgressIndicator(), style: TextStyle(color: _unFocusColor, fontSize: 12),
); )
],
)
.paddingSymmetric(horizontal: 8)
.animate(onPlay: (c) => c.repeat(reverse: true))
.fadeIn(duration: 1250.ms);
} }
if (widget.isColumn) { if (widget.isColumn) {
@ -159,6 +174,9 @@ class _AttachmentListState extends State<AttachmentList> {
if (element == null) return const SizedBox(); if (element == null) return const SizedBox();
double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9; double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9;
return Container( return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth, maxWidth: widget.columnMaxWidth,
maxHeight: 640, maxHeight: 640,
@ -204,6 +222,7 @@ class _AttachmentListState extends State<AttachmentList> {
final element = _attachmentsMeta[idx]; final element = _attachmentsMeta[idx];
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
border: Border.all( border: Border.all(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
width: 1, width: 1,

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart';
@ -7,10 +10,12 @@ import 'package:solian/exts.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/attachment_uploader.dart'; import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/stickers.dart'; import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/attachments/attachment_editor.dart';
import 'package:solian/widgets/chat/chat_event.dart'; import 'package:solian/widgets/chat/chat_event.dart';
@ -196,6 +201,36 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
} }
} }
Timer? _typingNotifyTimer;
bool _typingStatus = false;
Future<void> _sendTypingStatus() async {
final WebSocketProvider ws = Get.find();
ws.websocket?.sink.add(jsonEncode(
NetworkPackage(
method: 'status.typing',
endpoint: 'messaging',
payload: {
'channel_id': widget.channel.id,
},
).toJson(),
));
}
void _pingEnterMessageStatus() {
if (!_typingStatus) {
_sendTypingStatus();
_typingStatus = true;
}
if (_typingNotifyTimer == null || !_typingNotifyTimer!.isActive) {
_typingNotifyTimer?.cancel();
_typingNotifyTimer = Timer(const Duration(milliseconds: 1850), () {
_typingStatus = false;
});
}
}
void _resetInput() { void _resetInput() {
if (widget.onReset != null) widget.onReset!(); if (widget.onReset != null) widget.onReset!();
_editTo = null; _editTo = null;
@ -269,6 +304,20 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
} }
@override
void initState() {
super.initState();
_textController.addListener(_pingEnterMessageStatus);
}
@override
void dispose() {
_textController.removeListener(_pingEnterMessageStatus);
_textController.dispose();
_typingNotifyTimer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final notifyBannerActions = [ final notifyBannerActions = [

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/channel.dart';
class ChatTypingIndicator extends StatefulWidget {
final List<ChannelMember> users;
const ChatTypingIndicator({super.key, required this.users});
@override
State<ChatTypingIndicator> createState() => _ChatTypingIndicatorState();
}
class _ChatTypingIndicatorState extends State<ChatTypingIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
late final Animation<double> _animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant ChatTypingIndicator oldWidget) {
if (widget.users.isNotEmpty) {
_controller.animateTo(1);
} else {
_controller.animateTo(0);
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _animation,
axis: Axis.vertical,
axisAlignment: -1,
child: Row(
children: [
const Icon(Icons.more_horiz),
const SizedBox(width: 6),
Text('typingMessage'.trParams({
'user': widget.users.map((x) => x.account.nick).join(', '),
})),
],
).paddingSymmetric(horizontal: 16),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -17,8 +18,36 @@ class LinkExpansion extends StatelessWidget {
return SvgPicture.network(url, width: width, height: height); return SvgPicture.network(url, width: width, height: height);
} }
return PlatformInfo.canCacheImage return PlatformInfo.canCacheImage
? CachedNetworkImage(imageUrl: url, width: width, height: height) ? CachedNetworkImage(
: Image.network(url, width: width, height: height); imageUrl: url,
width: width,
height: height,
errorWidget: (context, url, error) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Center(
child: const Icon(Icons.close, size: 32)
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
),
);
},
)
: Image.network(
url,
width: width,
height: height,
errorBuilder: (context, error, stackTrace) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Center(
child: const Icon(Icons.close, size: 32)
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
),
);
},
);
} }
@override @override

View File

@ -197,10 +197,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer>
} else if (SolianTheme.isLargeScreen(context)) { } else if (SolianTheme.isLargeScreen(context)) {
_collapseDrawer(); _collapseDrawer();
} else { } else {
_drawerAnimationController.animateTo( _drawerAnimationController.value = 1;
1,
duration: const Duration(milliseconds: 100),
);
} }
} }

View File

@ -865,10 +865,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: ddc16d34b0d74cb313986918c0f0885a7ba2fc24d8fb8419de75f0015144ccfe sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.3" version: "14.2.7"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@ -953,10 +953,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: "8c5abf0dcc24fe6e8e0b4a5c0b51a5cf30cefdf6407a3213dae61edc75a70f56" sha256: c0a6763d50b354793d0192afd0a12560b823147d3ded7c6b77daf658fa05cc85
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.12+12" version: "0.8.12+13"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@ -1153,10 +1153,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit name: media_kit
sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a" sha256: "1f1deee148533d75129a6f38251ff8388e33ee05fc2d20a6a80e57d6051b7b62"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.10+1" version: "1.1.11"
media_kit_libs_android_video: media_kit_libs_android_video:
dependency: transitive dependency: transitive
description: description:
@ -1193,34 +1193,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_libs_video name: media_kit_libs_video
sha256: "3688e0c31482074578652bf038ce6301a5d21e1eda6b54fc3117ffeb4bdba067" sha256: "20bb4aefa8fece282b59580e1cd8528117297083a6640c98c2e98cfc96b93288"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
media_kit_libs_windows_video: media_kit_libs_windows_video:
dependency: transitive dependency: transitive
description: description:
name: media_kit_libs_windows_video name: media_kit_libs_windows_video
sha256: "7bace5f35d9afcc7f9b5cdadb7541d2191a66bb3fc71bfa11c1395b3360f6122" sha256: "32654572167825c42c55466f5d08eee23ea11061c84aa91b09d0e0f69bdd0887"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.10"
media_kit_native_event_loop: media_kit_native_event_loop:
dependency: transitive dependency: transitive
description: description:
name: media_kit_native_event_loop name: media_kit_native_event_loop
sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e sha256: "7d82e3b3e9ded5c35c3146c5ba1da3118d1dd8ac3435bac7f29f458181471b40"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.9"
media_kit_video: media_kit_video:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_video name: media_kit_video
sha256: c048d11a19e379aebbe810647636e3fc6d18374637e2ae12def4ff8a4b99a882 sha256: "2cc3b966679963ba25a4ce5b771e532a521ebde7c6aa20e9802bec95d9916c8f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.4" version: "1.2.5"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -1385,10 +1385,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_html name: permission_handler_html
sha256: d220eb8476b466d58b161e10b3001d93999010a26228a3fb89c4280db1249546 sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.3+1" version: "0.1.3+2"
permission_handler_platform_interface: permission_handler_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1481,10 +1481,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: process_run name: process_run
sha256: c917dfb5f7afad4c7485bc00a4df038621248fce046105020cea276d1a87c820 sha256: "112a77da35be50617ed9e2230df68d0817972f225e7f97ce8336f76b4e601606"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.2.0"
protobuf: protobuf:
dependency: transitive dependency: transitive
description: description:
@ -1934,10 +1934,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: f0c73347dfcfa5b3db8bc06e1502668265d39c08f310c29bff4e28eea9699f79 sha256: e35a698ac302dd68e41f73250bd9517fe3ab5fa4f18fe4647a0872db61bacbab
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.9" version: "6.3.10"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -2140,4 +2140,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.5.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0" flutter: ">=3.24.0"

View File

@ -2,7 +2,7 @@ name: solian
description: "The Solar Network App" description: "The Solar Network App"
publish_to: "none" publish_to: "none"
version: 1.2.1+21 version: 1.2.1+23
environment: environment:
sdk: ">=3.3.4 <4.0.0" sdk: ">=3.3.4 <4.0.0"