Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
7c0c1ec94f | |||
272044a77e | |||
39c22b1cf6 | |||
98c3bb912d | |||
035b92d9b8 | |||
0bfc0bd61b | |||
de00a20eee | |||
73982f48d6 | |||
1d36b30361 | |||
dea743a307 | |||
c48bd3e758 | |||
56bbf73b5e |
@ -71,6 +71,8 @@ PODS:
|
||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||
- Flutter (1.0.0)
|
||||
- flutter_keyboard_visibility (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- flutter_webrtc (0.11.3):
|
||||
@ -136,6 +138,8 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- pointer_interceptor_ios (0.0.1):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
- protocol_handler_ios (0.0.1):
|
||||
- Flutter
|
||||
@ -174,6 +178,7 @@ DEPENDENCIES:
|
||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
||||
- gal (from `.symlinks/plugins/gal/darwin`)
|
||||
@ -187,6 +192,7 @@ DEPENDENCIES:
|
||||
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
|
||||
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
|
||||
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
@ -229,6 +235,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/firebase_messaging/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_keyboard_visibility:
|
||||
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
flutter_webrtc:
|
||||
@ -255,6 +263,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
pointer_interceptor_ios:
|
||||
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
|
||||
protocol_handler_ios:
|
||||
:path: ".symlinks/plugins/protocol_handler_ios/ios"
|
||||
screen_brightness_ios:
|
||||
@ -288,6 +298,7 @@ SPEC CHECKSUMS:
|
||||
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
|
||||
FirebaseMessaging: 7b5d8033e183ab59eb5b852a53201559e976d366
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f
|
||||
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
|
||||
@ -304,6 +315,7 @@ SPEC CHECKSUMS:
|
||||
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
pointer_interceptor_ios: 508241697ff0947f853c061945a8b822463947c1
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990
|
||||
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
|
||||
|
@ -1,5 +1,7 @@
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/attachment.dart';
|
||||
import 'package:solian/services.dart';
|
||||
|
||||
class Sticker {
|
||||
int id;
|
||||
@ -30,6 +32,14 @@ class Sticker {
|
||||
required this.account,
|
||||
});
|
||||
|
||||
String get textPlaceholder => '${pack?.prefix}$alias'.camelCase!;
|
||||
String get textWarpedPlaceholder => ':$textPlaceholder:';
|
||||
|
||||
String get imageUrl => ServiceFinder.buildUrl(
|
||||
'files',
|
||||
'/attachments/$attachmentId',
|
||||
);
|
||||
|
||||
factory Sticker.fromJson(Map<String, dynamic> json) => Sticker(
|
||||
id: json['id'],
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
|
@ -5,7 +5,7 @@ import 'package:solian/services.dart';
|
||||
|
||||
class StickerProvider extends GetxController {
|
||||
final RxMap<String, String> aliasImageMapping = RxMap();
|
||||
final RxMap<String, List<Sticker>> availableStickers = RxMap();
|
||||
final RxList<Sticker> availableStickers = RxList.empty(growable: true);
|
||||
|
||||
Future<void> refreshAvailableStickers() async {
|
||||
final client = ServiceFinder.configureClient('files');
|
||||
@ -20,16 +20,9 @@ class StickerProvider extends GetxController {
|
||||
for (final pack in out) {
|
||||
for (final sticker in (pack.stickers ?? List<Sticker>.empty())) {
|
||||
sticker.pack = pack;
|
||||
final imageUrl = ServiceFinder.buildUrl(
|
||||
'files',
|
||||
'/attachments/${sticker.attachmentId}',
|
||||
);
|
||||
aliasImageMapping['${pack.prefix}${sticker.alias}'.camelCase!] =
|
||||
imageUrl;
|
||||
if (availableStickers[pack.prefix] == null) {
|
||||
availableStickers[pack.prefix] = List.empty(growable: true);
|
||||
}
|
||||
availableStickers[pack.prefix]!.add(sticker);
|
||||
sticker.imageUrl;
|
||||
availableStickers.add(sticker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -159,7 +159,16 @@ class _StickerScreenState extends State<StickerScreen> {
|
||||
builderDelegate: PagedChildBuilderDelegate(
|
||||
itemBuilder: (BuildContext context, item, int index) {
|
||||
return ExpansionTile(
|
||||
title: Text(item.name),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(item.name),
|
||||
const SizedBox(width: 6),
|
||||
Badge(
|
||||
label: Text('#${item.id}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
|
@ -136,8 +136,10 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Image.asset('assets/logo.png', width: 64, height: 64)
|
||||
.paddingOnly(bottom: 4),
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 4),
|
||||
Text(
|
||||
'signinGreeting'.tr,
|
||||
style: const TextStyle(
|
||||
|
@ -70,8 +70,10 @@ class _SignUpPopupState extends State<SignUpPopup> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Image.asset('assets/logo.png', width: 64, height: 64)
|
||||
.paddingOnly(bottom: 4),
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 4),
|
||||
Text(
|
||||
'signupGreeting'.tr,
|
||||
style: const TextStyle(
|
||||
|
@ -9,6 +9,7 @@ import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/chat/call/call_controls.dart';
|
||||
import 'package:solian/widgets/chat/call/call_participant.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as livekit;
|
||||
|
||||
class CallScreen extends StatefulWidget {
|
||||
const CallScreen({super.key});
|
||||
@ -161,13 +162,13 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
childAspectRatio: tileWidth / tileHeight,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: math.max(0, call.participantTracks.length),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final track = call.participantTracks[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
return Card(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: InteractiveParticipantWidget(
|
||||
@ -181,11 +182,10 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
).paddingAll(8);
|
||||
});
|
||||
}
|
||||
|
||||
@ -246,8 +246,77 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: 64,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Expanded(child: SizedBox()),
|
||||
Builder(builder: (context) {
|
||||
final call = Get.find<ChatCallProvider>();
|
||||
final connectionQuality =
|
||||
call.room.localParticipant?.connectionQuality ??
|
||||
livekit.ConnectionQuality.unknown;
|
||||
return Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(call.room.serverRegion ?? 'unknown'),
|
||||
const SizedBox(width: 6),
|
||||
Text(call.room.serverVersion ?? 'unknown')
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
{
|
||||
livekit.ConnectionState.disconnected:
|
||||
'callStatusDisconnected'.tr,
|
||||
livekit.ConnectionState.connected:
|
||||
'callStatusConnected'.tr,
|
||||
livekit.ConnectionState.connecting:
|
||||
'callStatusConnecting'.tr,
|
||||
livekit.ConnectionState.reconnecting:
|
||||
'callStatusReconnecting'.tr,
|
||||
}[call.room.connectionState]!,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (connectionQuality !=
|
||||
livekit.ConnectionQuality.unknown)
|
||||
Icon(
|
||||
{
|
||||
livekit.ConnectionQuality.excellent:
|
||||
Icons.signal_cellular_alt,
|
||||
livekit.ConnectionQuality.good:
|
||||
Icons.signal_cellular_alt_2_bar,
|
||||
livekit.ConnectionQuality.poor:
|
||||
Icons.signal_cellular_alt_1_bar,
|
||||
}[connectionQuality],
|
||||
color: {
|
||||
livekit.ConnectionQuality.excellent:
|
||||
Colors.green,
|
||||
livekit.ConnectionQuality.good:
|
||||
Colors.orange,
|
||||
livekit.ConnectionQuality.poor:
|
||||
Colors.red,
|
||||
}[connectionQuality],
|
||||
size: 16,
|
||||
)
|
||||
else
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
).paddingAll(3),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
IconButton(
|
||||
icon: _layoutMode == 0
|
||||
? const Icon(Icons.view_list)
|
||||
@ -257,7 +326,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
},
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 10),
|
||||
).paddingOnly(left: 20, right: 16),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
@ -363,4 +363,11 @@ const i18nEnglish = {
|
||||
'stickerUploaderNameHint':
|
||||
'A human-friendly name given to the user in the sticker selection interface.',
|
||||
'readMore': 'Read more',
|
||||
'attachmentUnload': 'Not Loaded',
|
||||
'attachmentUnloadCaption':
|
||||
'In order to save traffic, this attachment is not loaded automatically. Click it to start loading.',
|
||||
'callStatusConnected': 'Connected',
|
||||
'callStatusDisconnected': 'Disconnected',
|
||||
'callStatusConnecting': 'Connecting',
|
||||
'callStatusReconnected': 'Reconnecting',
|
||||
};
|
||||
|
@ -331,4 +331,10 @@ const i18nSimplifiedChinese = {
|
||||
'stickerUploaderName': '贴图名称',
|
||||
'stickerUploaderNameHint': '在贴图选择界面提供给用户的人类友好名称。',
|
||||
'readMore': '阅读更多',
|
||||
'attachmentUnload': '附件未加载',
|
||||
'attachmentUnloadCaption': '为了节省流量,本附件未自动加载,点一下来开始加载。',
|
||||
'callStatusConnected': '已连接',
|
||||
'callStatusDisconnected': '已断开',
|
||||
'callStatusConnecting': '连接中',
|
||||
'callStatusReconnected': '重连中',
|
||||
};
|
||||
|
@ -7,6 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:solian/models/attachment.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class AttachmentItem extends StatefulWidget {
|
||||
@ -213,8 +214,12 @@ class _AttachmentItemImage extends StatelessWidget {
|
||||
|
||||
class _AttachmentItemVideo extends StatefulWidget {
|
||||
final Attachment item;
|
||||
final bool autoload;
|
||||
|
||||
const _AttachmentItemVideo({required this.item});
|
||||
const _AttachmentItemVideo({
|
||||
required this.item,
|
||||
this.autoload = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AttachmentItemVideo> createState() => _AttachmentItemVideoState();
|
||||
@ -226,21 +231,64 @@ class _AttachmentItemVideoState extends State<_AttachmentItemVideo> {
|
||||
);
|
||||
late final _controller = VideoController(_player);
|
||||
|
||||
bool _showContent = false;
|
||||
|
||||
void _startLoad() {
|
||||
_player.open(
|
||||
Media(ServiceFinder.buildUrl('files', '/attachments/${widget.item.id}')),
|
||||
play: false,
|
||||
);
|
||||
setState(() => _showContent = true);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_player.open(
|
||||
Media(
|
||||
ServiceFinder.buildUrl('files', '/attachments/${widget.item.id}'),
|
||||
),
|
||||
play: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = widget.item.metadata?['ratio'] ?? 16 / 9;
|
||||
if (!_showContent) {
|
||||
return GestureDetector(
|
||||
child: AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: CenteredContainer(
|
||||
maxWidth: 280,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.not_started,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'attachmentUnload'.tr,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'attachmentUnloadCaption'.tr,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
_startLoad();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Video(
|
||||
aspectRatio: widget.item.metadata?['ratio'] ?? 16 / 9,
|
||||
aspectRatio: ratio,
|
||||
controller: _controller,
|
||||
);
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import 'package:solian/models/attachment.dart';
|
||||
import 'package:solian/widgets/attachments/attachment_item.dart';
|
||||
import 'package:solian/providers/content/attachment.dart';
|
||||
import 'package:solian/widgets/attachments/attachment_fullscreen.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
|
||||
class AttachmentList extends StatefulWidget {
|
||||
final String parentId;
|
||||
@ -272,9 +273,8 @@ class AttachmentListEntry extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (item!.isMature && !showMature)
|
||||
Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
CenteredContainer(
|
||||
maxWidth: 280,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@ -302,7 +302,6 @@ class AttachmentListEntry extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -322,3 +321,50 @@ class AttachmentListEntry extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentSelfContainedEntry extends StatefulWidget {
|
||||
final int id;
|
||||
final String parentId;
|
||||
final bool isDense;
|
||||
|
||||
const AttachmentSelfContainedEntry({
|
||||
super.key,
|
||||
required this.id,
|
||||
required this.parentId,
|
||||
this.isDense = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AttachmentSelfContainedEntry> createState() =>
|
||||
_AttachmentSelfContainedEntryState();
|
||||
}
|
||||
|
||||
class _AttachmentSelfContainedEntryState
|
||||
extends State<AttachmentSelfContainedEntry> {
|
||||
bool _showMature = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AttachmentProvider attachments = Get.find();
|
||||
|
||||
return FutureBuilder(
|
||||
future: attachments.getMetadata(widget.id),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Text('Loading...');
|
||||
}
|
||||
|
||||
return AttachmentListEntry(
|
||||
item: snapshot.data,
|
||||
isDense: widget.isDense,
|
||||
parentId: widget.parentId,
|
||||
showMature: _showMature,
|
||||
showBorder: true,
|
||||
onReveal: (value) {
|
||||
setState(() => _showMature = value);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -50,10 +50,10 @@ class ChatEvent extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.attachment,
|
||||
size: 18,
|
||||
Icons.file_copy,
|
||||
size: 15,
|
||||
color: unFocusColor,
|
||||
).paddingOnly(right: 6),
|
||||
).paddingOnly(right: 5),
|
||||
Text(
|
||||
'attachmentHint'.trParams(
|
||||
{'count': attachments.length.toString()},
|
||||
@ -221,6 +221,8 @@ class ChatEvent extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
_buildContent().paddingOnly(left: 0.5),
|
||||
_buildAttachment(context, isMinimal: true)
|
||||
.paddingOnly(left: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -31,7 +31,11 @@ class ChatEventMessageActionLog extends StatelessWidget {
|
||||
).paddingOnly(
|
||||
left: isQuote ? 0 : (isMerged ? 64 : 12),
|
||||
top: 2,
|
||||
bottom: isHasMerged ? 2 : 0,
|
||||
bottom: isQuote
|
||||
? 0
|
||||
: isHasMerged
|
||||
? 2
|
||||
: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -43,7 +43,11 @@ class ChatEventMessage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return MarkdownTextContent(content: body.text);
|
||||
return MarkdownTextContent(
|
||||
parentId: 'm${item.id}',
|
||||
isSelectable: true,
|
||||
content: body.text,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
@ -63,7 +67,9 @@ class ChatEventMessage extends StatelessWidget {
|
||||
left: isQuote ? 0 : 12,
|
||||
right: isQuote ? 0 : 12,
|
||||
top: body.quoteEvent == null ? 2 : 0,
|
||||
bottom: hasAttachment && !isContentPreviewing ? 4 : (isHasMerged ? 2 : 0),
|
||||
bottom: hasAttachment && !isContentPreviewing && !isQuote
|
||||
? 4
|
||||
: (isHasMerged ? 2 : 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,36 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/models/event.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/attachment_uploader.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/stickers.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/attachments/attachment_editor.dart';
|
||||
import 'package:solian/widgets/chat/chat_event.dart';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChatMessageSuggestion {
|
||||
final String type;
|
||||
final Widget leading;
|
||||
final String display;
|
||||
final String content;
|
||||
|
||||
ChatMessageSuggestion({
|
||||
required this.type,
|
||||
required this.leading,
|
||||
required this.display,
|
||||
required this.content,
|
||||
});
|
||||
}
|
||||
|
||||
class ChatMessageInput extends StatefulWidget {
|
||||
final Event? edit;
|
||||
final Event? reply;
|
||||
@ -156,7 +175,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
widget.onSent(message);
|
||||
}
|
||||
|
||||
resetInput();
|
||||
_resetInput();
|
||||
|
||||
if (_editTo != null) {
|
||||
resp = await client.put(
|
||||
@ -175,7 +194,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
}
|
||||
}
|
||||
|
||||
void resetInput() {
|
||||
void _resetInput() {
|
||||
if (widget.onReset != null) widget.onReset!();
|
||||
_editTo = null;
|
||||
_replyTo = null;
|
||||
@ -184,11 +203,13 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void syncWidget() {
|
||||
void _syncWidget() {
|
||||
if (widget.edit != null && widget.edit!.type.startsWith('messages')) {
|
||||
final body = EventMessageBody.fromJson(widget.edit!.body);
|
||||
_editTo = widget.edit!;
|
||||
_textController.text = body.text;
|
||||
_attachments.addAll(
|
||||
widget.edit!.body['attachments']?.cast<int>() ?? List.empty());
|
||||
}
|
||||
if (widget.reply != null) {
|
||||
_replyTo = widget.reply!;
|
||||
@ -197,9 +218,52 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Widget _buildSuggestion(ChatMessageSuggestion suggestion) {
|
||||
return ListTile(
|
||||
leading: suggestion.leading,
|
||||
title: Text(suggestion.display),
|
||||
subtitle: Text(suggestion.content),
|
||||
);
|
||||
}
|
||||
|
||||
void _insertSuggestion(ChatMessageSuggestion suggestion) {
|
||||
final replaceText =
|
||||
_textController.text.substring(0, _textController.selection.baseOffset);
|
||||
var startText = '';
|
||||
final afterText = replaceText == _textController.text
|
||||
? ''
|
||||
: _textController.text
|
||||
.substring(_textController.selection.baseOffset + 1);
|
||||
var insertText = '';
|
||||
|
||||
if (suggestion.type == 'emotes') {
|
||||
insertText = suggestion.content;
|
||||
startText = replaceText.replaceFirstMapped(
|
||||
RegExp(r':(?:([a-z0-9_+-]+)~)?([a-z0-9_+-]+)$'),
|
||||
(Match m) => insertText,
|
||||
);
|
||||
}
|
||||
|
||||
if (suggestion.type == 'users') {
|
||||
insertText = suggestion.content;
|
||||
startText = replaceText.replaceFirstMapped(
|
||||
RegExp(r'(?:\s|^)@([a-z0-9_+-]+)$'),
|
||||
(Match m) => insertText,
|
||||
);
|
||||
}
|
||||
|
||||
if (insertText.isNotEmpty && startText.isNotEmpty) {
|
||||
_textController.text = startText + afterText;
|
||||
_textController.selection = TextSelection(
|
||||
baseOffset: startText.length,
|
||||
extentOffset: startText.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChatMessageInput oldWidget) {
|
||||
syncWidget();
|
||||
_syncWidget();
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@ -207,7 +271,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
Widget build(BuildContext context) {
|
||||
final notifyBannerActions = [
|
||||
TextButton(
|
||||
onPressed: resetInput,
|
||||
onPressed: _resetInput,
|
||||
child: Text('cancel'.tr),
|
||||
)
|
||||
];
|
||||
@ -251,7 +315,20 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
child: TypeAheadField<ChatMessageSuggestion>(
|
||||
direction: VerticalDirection.up,
|
||||
hideOnEmpty: true,
|
||||
hideOnLoading: true,
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
hideOnSelect: false,
|
||||
debounceDuration: const Duration(milliseconds: 500),
|
||||
onSelected: (value) {
|
||||
_insertSuggestion(value);
|
||||
},
|
||||
itemBuilder: (context, item) => _buildSuggestion(item),
|
||||
builder: (context, controller, focusNode) {
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLines: null,
|
||||
@ -259,13 +336,78 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: widget.placeholder ??
|
||||
'messageInputPlaceholder'.trParams(
|
||||
{'channel': '#${widget.channel.alias}'},
|
||||
),
|
||||
'messageInputPlaceholder'.trParams({
|
||||
'channel': '#${widget.channel.alias}',
|
||||
}),
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
);
|
||||
},
|
||||
suggestionsCallback: (search) async {
|
||||
final searchText = _textController.text
|
||||
.substring(0, _textController.selection.baseOffset);
|
||||
|
||||
final emojiMatch =
|
||||
RegExp(r':(?:([a-z0-9_+-]+)~)?([a-z0-9_+-]+)$')
|
||||
.firstMatch(searchText);
|
||||
if (emojiMatch != null) {
|
||||
final StickerProvider stickers = Get.find();
|
||||
final emoteSearch = emojiMatch[2]!;
|
||||
return stickers.availableStickers
|
||||
.where((x) =>
|
||||
x.textWarpedPlaceholder.contains(emoteSearch))
|
||||
.map(
|
||||
(x) => ChatMessageSuggestion(
|
||||
type: 'emotes',
|
||||
leading: PlatformInfo.canCacheImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: x.imageUrl,
|
||||
width: 28,
|
||||
height: 28,
|
||||
)
|
||||
: Image.network(
|
||||
x.imageUrl,
|
||||
width: 28,
|
||||
height: 28,
|
||||
),
|
||||
display: x.name,
|
||||
content: x.textWarpedPlaceholder,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final userMatch = RegExp(r'(?:\s|^)@([a-z0-9_+-]+)$')
|
||||
.firstMatch(searchText);
|
||||
if (userMatch != null) {
|
||||
final userSearch = userMatch[1]!.toLowerCase();
|
||||
final AuthProvider auth = Get.find();
|
||||
|
||||
final client = auth.configureClient('auth');
|
||||
final resp = await client.get(
|
||||
'/users/search?probe=$userSearch',
|
||||
);
|
||||
|
||||
final List<Account> result = resp.body
|
||||
.map((x) => Account.fromJson(x))
|
||||
.toList()
|
||||
.cast<Account>();
|
||||
return result
|
||||
.map(
|
||||
(x) => ChatMessageSuggestion(
|
||||
type: 'users',
|
||||
leading: AccountAvatar(content: x.avatar),
|
||||
display: x.nick,
|
||||
content: '@${x.name}',
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
|
@ -1,24 +1,33 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_selectionarea/flutter_markdown.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:markdown/markdown.dart' as markdown;
|
||||
import 'package:markdown/markdown.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/stickers.dart';
|
||||
import 'package:solian/widgets/attachments/attachment_list.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'account/account_profile_popup.dart';
|
||||
|
||||
class MarkdownTextContent extends StatelessWidget {
|
||||
final String content;
|
||||
final String parentId;
|
||||
final bool isSelectable;
|
||||
|
||||
const MarkdownTextContent({
|
||||
super.key,
|
||||
required this.content,
|
||||
required this.parentId,
|
||||
this.isSelectable = false,
|
||||
});
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
final emojiMatch = RegExp(r':([a-z0-9_+-]+):').allMatches(content);
|
||||
final isOnlyEmoji =
|
||||
content.replaceAll(RegExp(r':([a-z0-9_+-]+):'), '').trim().isEmpty;
|
||||
|
||||
return Markdown(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@ -42,6 +51,7 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
_UserNameCardInlineSyntax(),
|
||||
_CustomEmoteInlineSyntax(),
|
||||
markdown.EmojiSyntax(),
|
||||
markdown.AutolinkSyntax(),
|
||||
markdown.AutolinkExtensionSyntax(),
|
||||
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
|
||||
],
|
||||
@ -70,6 +80,58 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
imageBuilder: (uri, title, alt) {
|
||||
var url = uri.toString();
|
||||
double? width, height;
|
||||
BoxFit? fit;
|
||||
if (url.startsWith('solink://')) {
|
||||
final segments = url.replaceFirst('solink://', '').split('/');
|
||||
switch (segments[0]) {
|
||||
case 'stickers':
|
||||
final StickerProvider sticker = Get.find();
|
||||
url = sticker.aliasImageMapping[segments[1]]!;
|
||||
if (emojiMatch.length <= 1 && isOnlyEmoji) {
|
||||
width = 112;
|
||||
height = 112;
|
||||
} else if (emojiMatch.length <= 3 && isOnlyEmoji) {
|
||||
width = 56;
|
||||
height = 56;
|
||||
} else {
|
||||
width = 28;
|
||||
height = 28;
|
||||
}
|
||||
fit = BoxFit.contain;
|
||||
break;
|
||||
case 'attachments':
|
||||
const radius = BorderRadius.all(Radius.circular(8));
|
||||
return LimitedBox(
|
||||
maxHeight: MediaQuery.of(context).size.width,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: AttachmentSelfContainedEntry(
|
||||
isDense: true,
|
||||
parentId: parentId,
|
||||
id: int.parse(segments[1]),
|
||||
),
|
||||
),
|
||||
).paddingSymmetric(vertical: 4);
|
||||
}
|
||||
}
|
||||
|
||||
return PlatformInfo.canCacheImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
)
|
||||
: Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -111,7 +173,7 @@ class _CustomEmoteInlineSyntax extends InlineSyntax {
|
||||
}
|
||||
|
||||
final element = markdown.Element.empty('img');
|
||||
element.attributes['src'] = sticker.aliasImageMapping[alias]!;
|
||||
element.attributes['src'] = 'solink://stickers/$alias';
|
||||
parser.addNode(element);
|
||||
|
||||
return true;
|
||||
|
@ -295,6 +295,7 @@ class _PostItemState extends State<PostItem> {
|
||||
setState(() => _contentHeight = size.height);
|
||||
},
|
||||
child: MarkdownTextContent(
|
||||
parentId: 'p${item.id}',
|
||||
content: item.body['content'],
|
||||
isSelectable: widget.isContentSelectable,
|
||||
).paddingOnly(
|
||||
@ -334,10 +335,10 @@ class _PostItemState extends State<PostItem> {
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.attachment,
|
||||
size: 18,
|
||||
Icons.file_copy,
|
||||
size: 15,
|
||||
color: _unFocusColor,
|
||||
).paddingOnly(right: 6),
|
||||
).paddingOnly(right: 5),
|
||||
Text(
|
||||
'attachmentHint'.trParams(
|
||||
{'count': attachments.length.toString()},
|
||||
@ -389,6 +390,7 @@ class _PostItemState extends State<PostItem> {
|
||||
setState(() => _contentHeight = size.height);
|
||||
},
|
||||
child: MarkdownTextContent(
|
||||
parentId: 'p${item.id}',
|
||||
content: item.body['content'],
|
||||
isSelectable: widget.isContentSelectable,
|
||||
).paddingOnly(left: 12, right: 8),
|
||||
@ -438,7 +440,7 @@ class _PostItemState extends State<PostItem> {
|
||||
attachmentsId: attachments,
|
||||
isGrid: attachments.length > 1,
|
||||
),
|
||||
if (widget.isShowReply && widget.isReactable)
|
||||
if (widget.isShowReply || widget.isReactable)
|
||||
PostQuickAction(
|
||||
isShowReply: widget.isShowReply,
|
||||
isReactable: widget.isReactable,
|
||||
@ -501,7 +503,6 @@ class _MeasureSize extends SingleChildRenderObjectWidget {
|
||||
final _OnWidgetSizeChange onChange;
|
||||
|
||||
const _MeasureSize({
|
||||
super.key,
|
||||
required this.onChange,
|
||||
required Widget super.child,
|
||||
});
|
||||
|
88
pubspec.lock
88
pubspec.lock
@ -566,6 +566,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter_keyboard_visibility:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility
|
||||
sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_keyboard_visibility_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility_linux
|
||||
sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter_keyboard_visibility_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility_macos
|
||||
sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter_keyboard_visibility_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility_platform_interface
|
||||
sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
flutter_keyboard_visibility_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility_web
|
||||
sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
flutter_keyboard_visibility_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility_windows
|
||||
sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -675,6 +723,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_typeahead:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_typeahead
|
||||
sha256: d64712c65db240b1057559b952398ebb6e498077baeebf9b0731dade62438a6d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.0"
|
||||
flutter_web_plugins:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -1288,6 +1344,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointer_interceptor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointer_interceptor
|
||||
sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1+2"
|
||||
pointer_interceptor_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointer_interceptor_ios
|
||||
sha256: a6906772b3205b42c44614fcea28f818b1e5fdad73a4ca742a7bd49818d9c917
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
pointer_interceptor_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointer_interceptor_platform_interface
|
||||
sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.0+1"
|
||||
pointer_interceptor_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointer_interceptor_web
|
||||
sha256: "7a7087782110f8c1827170660b09f8aa893e0e9a61431dbbe2ac3fc482e8c044"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.2+1"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -2,7 +2,7 @@ name: solian
|
||||
description: "The Solar Network App"
|
||||
publish_to: "none"
|
||||
|
||||
version: 1.2.1+6
|
||||
version: 1.2.1+8
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.4 <4.0.0"
|
||||
@ -67,6 +67,7 @@ dependencies:
|
||||
avatar_stack: ^1.2.0
|
||||
async: ^2.11.0
|
||||
field_suggestion: ^0.2.5
|
||||
flutter_typeahead: ^5.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Reference in New Issue
Block a user