Chat basis

This commit is contained in:
LittleSheep 2024-05-26 01:21:08 +08:00
parent 657f36c1f8
commit 5b45718ebd
16 changed files with 379 additions and 28 deletions

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/realm.dart';
class Channel {
int id;
@ -13,6 +14,7 @@ class Channel {
int type;
Account account;
int accountId;
Realm? realm;
int? realmId;
bool isEncrypted;
@ -30,6 +32,7 @@ class Channel {
required this.account,
required this.accountId,
required this.isEncrypted,
this.realm,
this.realmId,
});
@ -44,6 +47,7 @@ class Channel {
type: json['type'],
account: Account.fromJson(json['account']),
accountId: json['account_id'],
realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null,
realmId: json['realm_id'],
isEncrypted: json['is_encrypted'],
);
@ -57,8 +61,9 @@ class Channel {
'name': name,
'description': description,
'type': type,
'account': account,
'account': account.toJson(),
'account_id': accountId,
'realm': realm?.toJson(),
'realm_id': realmId,
'is_encrypted': isEncrypted,
};

View File

@ -8,7 +8,7 @@ class Message {
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String rawContent;
Map<String, dynamic> content;
Map<String, dynamic>? metadata;
String type;
List<String>? attachments;
@ -21,16 +21,12 @@ class Message {
bool isSending = false;
Map<String, dynamic> get decodedContent {
return jsonDecode(utf8.fuse(base64).decode(rawContent));
}
Message({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.rawContent,
required this.content,
required this.metadata,
required this.type,
this.attachments,
@ -47,14 +43,16 @@ class Message {
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
rawContent: json['content'],
content: json['content'],
metadata: json['metadata'],
type: json['type'],
attachments: json['attachments'],
channel: Channel.fromJson(json['channel']),
sender: Sender.fromJson(json['sender']),
replyId: json['reply_id'],
replyTo: json['reply_to'] != null ? Message.fromJson(json['reply_to']) : null,
replyTo: json['reply_to'] != null
? Message.fromJson(json['reply_to'])
: null,
channelId: json['channel_id'],
senderId: json['sender_id'],
);
@ -64,7 +62,7 @@ class Message {
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'content': rawContent,
'content': content,
'metadata': metadata,
'type': type,
'attachments': attachments,

View File

@ -10,7 +10,7 @@ class Realm {
String description;
bool isPublic;
bool isCommunity;
int accountId;
int? accountId;
Realm({
required this.id,
@ -22,14 +22,16 @@ class Realm {
required this.description,
required this.isPublic,
required this.isCommunity,
required this.accountId,
this.accountId,
});
factory Realm.fromJson(Map<String, dynamic> json) => Realm(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null,
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: null,
alias: json['alias'],
name: json['name'],
description: json['description'],
@ -77,7 +79,9 @@ class RealmMember {
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null,
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: null,
realmId: json['realm_id'],
accountId: json['account_id'],
account: Account.fromJson(json['account']),

View File

@ -3,6 +3,21 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
class ChannelProvider extends GetxController {
Future<Response> getChannel(String alias, {String realm = 'global'}) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');
final client = GetConnect();
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
final resp = await client.get('/api/channels/$realm/$alias');
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return resp;
}
Future<Response> listAvailableChannel({String realm = 'global'}) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');

View File

@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart';
import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/channel/channel_chat.dart';
import 'package:solian/screens/channel/channel_organize.dart';
import 'package:solian/screens/contact.dart';
import 'package:solian/screens/posts/post_detail.dart';
@ -87,6 +88,16 @@ abstract class AppRouter {
);
},
),
GoRoute(
path: '/chat/:alias',
name: 'channelChat',
builder: (context, state) {
return ChannelChatScreen(
alias: state.pathParameters['alias']!,
realm: state.uri.queryParameters['realm'] ?? 'global',
);
},
),
],
);
}

View File

@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/services.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/chat/chat_message.dart';
class ChannelChatScreen extends StatefulWidget {
final String alias;
final String realm;
const ChannelChatScreen({
super.key,
required this.alias,
this.realm = 'global',
});
@override
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
}
class _ChannelChatScreenState extends State<ChannelChatScreen> {
bool _isBusy = false;
Channel? _channel;
final PagingController<int, Message> _pagingController =
PagingController(firstPageKey: 0);
getChannel() async {
final ChannelProvider provider = Get.find();
setState(() => _isBusy = true);
try {
final resp = await provider.getChannel(widget.alias, realm: widget.realm);
setState(() => _channel = Channel.fromJson(resp.body));
} catch (e) {
context.showErrorDialog(e);
}
setState(() => _isBusy = false);
}
Future<void> getMessages(int pageKey) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
final client = GetConnect();
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.get(
'/api/channels/${widget.realm}/${widget.alias}/messages?take=10&offset=$pageKey');
if (resp.statusCode == 200) {
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => Message.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
_pagingController.appendLastPage(parsed);
}
} else if (resp.statusCode == 403) {
_pagingController.appendLastPage([]);
} else {
_pagingController.error = resp.bodyString;
}
}
bool checkMessageMergeable(Message? a, Message? b) {
if (a?.replyTo != null) return false;
if (a == null || b == null) return false;
if (a.senderId != b.senderId) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
Widget chatHistoryBuilder(context, item, index) {
bool isMerged = false, hasMerged = false;
if (index > 0) {
hasMerged = checkMessageMergeable(
_pagingController.itemList?[index - 1],
item,
);
}
if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
isMerged = checkMessageMergeable(
item,
_pagingController.itemList?[index + 1],
);
}
return InkWell(
child: Container(
padding: EdgeInsets.only(
top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
left: 12,
right: 12,
),
child: ChatMessage(
item: item,
isCompact: isMerged,
),
),
onLongPress: () {},
).animate(key: Key('m${item.id}'), autoPlay: true).slideY(
curve: Curves.fastEaseInToSlowEaseOut,
duration: 350.ms,
begin: 0.25,
end: 0,
);
}
@override
void initState() {
super.initState();
getChannel().then((_) {
_pagingController.addPageRequestListener(getMessages);
});
}
@override
Widget build(BuildContext context) {
if (_isBusy) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_channel?.name ?? 'loading'.tr),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {},
),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
body: Column(
children: [
Expanded(
child: PagedListView<int, Message>(
reverse: true,
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Message>(
animateTransitions: true,
transitionDuration: 350.ms,
itemBuilder: chatHistoryBuilder,
noItemsFoundIndicatorBuilder: (_) => Container(),
),
),
),
],
),
);
}
}

View File

@ -78,8 +78,9 @@ class _ContactScreenState extends State<ContactScreen> {
);
},
),
if (!SolianTheme.isLargeScreen(context))
const SizedBox(width: 16),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
),
@ -111,7 +112,16 @@ class _ContactScreenState extends State<ContactScreen> {
const EdgeInsets.symmetric(horizontal: 24),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () {},
onTap: () {
AppRouter.instance.pushNamed(
'channelChat',
pathParameters: {'alias': element.alias},
queryParameters: {
if (element.realmId != null)
'realm': element.realm!.alias,
},
);
},
);
},
),

View File

@ -87,8 +87,9 @@ class _SocialScreenState extends State<SocialScreen> {
forceElevated: innerBoxIsScrolled,
actions: [
const NotificationButton(),
if (!SolianTheme.isLargeScreen(context))
const SizedBox(width: 16),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
),

View File

@ -14,6 +14,7 @@ class SolianMessages extends Translations {
'apply': 'Apply',
'cancel': 'Cancel',
'confirm': 'Confirm',
'loading': 'Loading...',
'edit': 'Edit',
'delete': 'Delete',
'search': 'Search',
@ -100,6 +101,8 @@ class SolianMessages extends Translations {
'channelType': 'Channel type',
'channelTypeCommon': 'Regular',
'channelTypeDirect': 'DM',
'messageDecoding': 'Decoding...',
'messageDecodeFailed': 'Unable to decode: @message',
},
'zh_CN': {
'hide': '隐藏',
@ -108,6 +111,7 @@ class SolianMessages extends Translations {
'reset': '重置',
'cancel': '取消',
'confirm': '确认',
'loading': '载入中…',
'edit': '编辑',
'delete': '删除',
'page': '页面',
@ -191,6 +195,8 @@ class SolianMessages extends Translations {
'channelType': '频道类型',
'channelTypeCommon': '普通频道',
'channelTypeDirect': '私信聊天',
'messageDecoding': '解码信息中…',
'messageDecodeFailed': '解码信息失败:@message',
}
};
}

View File

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:get/get.dart';
import 'package:solian/models/message.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:timeago/timeago.dart' show format;
class ChatMessage extends StatelessWidget {
final Message item;
final bool isCompact;
const ChatMessage({super.key, required this.item, required this.isCompact});
Future<String?> decodeContent(Map<String, dynamic> content) async {
String? text;
if (item.type == 'm.text') {
switch (content['algorithm']) {
case 'plain':
text = content['value'];
default:
throw Exception('Unsupported algorithm');
// TODO Impl AES algorithm
}
}
return text;
}
@override
Widget build(BuildContext context) {
final hasAttachment = item.attachments?.isNotEmpty ?? false;
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountAvatar(content: item.sender.account.avatar),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.sender.account.nick,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 4),
Text(format(item.createdAt, locale: 'en_short'))
],
).paddingSymmetric(horizontal: 12),
FutureBuilder(
future: decodeContent(item.content),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Opacity(
opacity: 0.8,
child: Row(
children: [
const Icon(Icons.more_horiz),
Text('messageDecoding'.tr)
],
),
)
.animate(onPlay: (c) => c.repeat())
.fade(begin: 0, end: 1);
} else if (snapshot.hasError) {
return Opacity(
opacity: 0.9,
child: Row(
children: [
const Icon(Icons.close),
Text(
'messageDecodeFailed'.trParams(
{'message': snapshot.error.toString()}),
)
],
),
);
}
return Markdown(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: snapshot.data ?? '',
padding: const EdgeInsets.all(0),
).paddingOnly(
left: 12,
right: 12,
top: 2,
bottom: hasAttachment ? 4 : 0,
);
}),
],
),
),
],
),
],
);
}
}

View File

@ -15,18 +15,21 @@ class _AppNavigationBottomBarState extends State<AppNavigationBottomBar> {
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
items: AppNavigation.destinations.map(
items: AppNavigation.destinations
.map(
(e) => BottomNavigationBarItem(
icon: e.icon,
label: e.label,
),
).toList(),
)
.toList(),
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
currentIndex: _selectedIndex,
showUnselectedLabels: false,
onTap: (idx) {
setState(() => _selectedIndex = idx);
AppRouter.instance.goNamed(AppNavigation.destinations[idx].page);
AppRouter.instance
.pushReplacementNamed(AppNavigation.destinations[idx].page);
},
);
}

View File

@ -27,7 +27,8 @@ class _AppNavigationRailState extends State<AppNavigationRail> {
selectedIndex: _selectedIndex,
onDestinationSelected: (idx) {
setState(() => _selectedIndex = idx);
AppRouter.instance.pushNamed(AppNavigation.destinations[idx].page);
AppRouter.instance
.pushReplacementNamed(AppNavigation.destinations[idx].page);
},
);
}

View File

@ -1,6 +1,8 @@
PODS:
- file_selector_macos (0.0.1):
- FlutterMacOS
- flutter_local_notifications (0.0.1):
- FlutterMacOS
- flutter_secure_storage_macos (6.1.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
@ -12,6 +14,7 @@ PODS:
DEPENDENCIES:
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
@ -20,6 +23,8 @@ DEPENDENCIES:
EXTERNAL SOURCES:
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
flutter_secure_storage_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
FlutterMacOS:
@ -31,6 +36,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
file_selector_macos: 54fdab7caa3ac3fc43c9fac4d7d8d231277f8cf2
flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4
flutter_secure_storage_macos: 59459653abe1adb92abbc8ea747d79f8d19866c9
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46

View File

@ -120,7 +120,6 @@
1B3B8DF79807852D828EBE0C /* Pods-RunnerTests.release.xcconfig */,
BA5247A2B03173FDFDFCFF93 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@ -724,13 +723,17 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_HARDENED_RUNTIME = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};

View File

@ -22,6 +22,11 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict/>
</dict>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>

View File

@ -4,5 +4,13 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>