Initial commit
This commit is contained in:
1665
lib/pages/chat/chat.dart
Normal file
1665
lib/pages/chat/chat.dart
Normal file
File diff suppressed because it is too large
Load Diff
68
lib/pages/chat/chat_app_bar_list_tile.dart
Normal file
68
lib/pages/chat/chat_app_bar_list_tile.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class ChatAppBarListTile extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final void Function()? onTap;
|
||||
|
||||
static const double fixedHeight = 40.0;
|
||||
|
||||
const ChatAppBarListTile({
|
||||
super.key,
|
||||
this.leading,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final leading = this.leading;
|
||||
final trailing = this.trailing;
|
||||
return SizedBox(
|
||||
height: fixedHeight,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
?leading,
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Linkify(
|
||||
text: title,
|
||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
lib/pages/chat/chat_app_bar_title.dart
Normal file
179
lib/pages/chat/chat_app_bar_title.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/utils/date_time_extension.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:tatchat/utils/sync_status_localization.dart';
|
||||
import 'package:tatchat/utils/verified_room_extension.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/presence_builder.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class ChatAppBarTitle extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
const ChatAppBarTitle(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final room = controller.room;
|
||||
if (controller.selectedEvents.isNotEmpty) {
|
||||
return Text(
|
||||
controller.selectedEvents.length.toString(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onTertiaryContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
return InkWell(
|
||||
hoverColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
onTap: controller.isArchived
|
||||
? null
|
||||
: () => FluffyThemes.isThreeColumnMode(context)
|
||||
? controller.toggleDisplayChatDetailsColumn()
|
||||
: context.go('/rooms/${room.id}/details'),
|
||||
child: Row(
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'content_banner',
|
||||
child: Avatar(
|
||||
mxContent: room.avatar,
|
||||
name: room.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
),
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (room.allUsersVerified)
|
||||
Icon(
|
||||
Icons.verified,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
room.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StreamBuilder(
|
||||
stream: room.client.onSyncStatus.stream,
|
||||
builder: (context, snapshot) {
|
||||
final status =
|
||||
room.client.onSyncStatus.value ??
|
||||
const SyncStatusUpdate(SyncStatus.waitingForResponse);
|
||||
final hide =
|
||||
FluffyThemes.isColumnMode(context) ||
|
||||
(room.client.onSync.value != null &&
|
||||
status.status != SyncStatus.error &&
|
||||
room.client.prevBatch != null);
|
||||
final style = TextStyle(fontSize: 11);
|
||||
return AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
child: hide
|
||||
? room.isDirectChat
|
||||
? PresenceBuilder(
|
||||
userId: room.directChatMatrixID,
|
||||
builder: (context, presence) {
|
||||
final statusMessage = presence?.statusMsg
|
||||
?.trim();
|
||||
|
||||
final lastActiveTimestamp =
|
||||
presence?.lastActiveTimestamp;
|
||||
|
||||
final texts = [
|
||||
if (presence?.currentlyActive == true)
|
||||
L10n.of(context).currentlyActive
|
||||
else if (lastActiveTimestamp != null)
|
||||
L10n.of(context).lastActiveAgo(
|
||||
lastActiveTimestamp
|
||||
.localizedTimeShort(context),
|
||||
),
|
||||
?statusMessage,
|
||||
];
|
||||
final text = texts.join(' ◦ ');
|
||||
if (text.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Text(text, style: style);
|
||||
},
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).countParticipants(
|
||||
(room.summary.mJoinedMemberCount ??
|
||||
1) +
|
||||
(room
|
||||
.summary
|
||||
.mInvitedMemberCount ??
|
||||
0),
|
||||
),
|
||||
maxLines: 1,
|
||||
style: style,
|
||||
),
|
||||
if (room.topic.isNotEmpty) ...[
|
||||
Text(' ◦ ', style: style),
|
||||
Expanded(
|
||||
child: Text(
|
||||
room.topic,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 10,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 1,
|
||||
value: status.progress,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
status.calcLocalizedString(context),
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/pages/chat/chat_emoji_picker.dart
Normal file
125
lib/pages/chat/chat_emoji_picker.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
|
||||
import 'package:tatchat/pages/chat/trust_user_key_dialog.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'chat.dart';
|
||||
|
||||
class ChatEmojiPicker extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
const ChatEmojiPicker(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
height: controller.showEmojiPicker
|
||||
? MediaQuery.sizeOf(context).height / 2
|
||||
: 0,
|
||||
child: controller.showEmojiPicker
|
||||
? DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: L10n.of(context).emojis),
|
||||
Tab(text: L10n.of(context).stickers),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
EmojiPicker(
|
||||
onEmojiSelected: controller.onEmojiSelected,
|
||||
onBackspacePressed: controller.emojiPickerBackspace,
|
||||
config: Config(
|
||||
locale: Localizations.localeOf(context),
|
||||
emojiViewConfig: EmojiViewConfig(
|
||||
noRecents: const NoRecent(),
|
||||
backgroundColor:
|
||||
theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
bottomActionBarConfig: const BottomActionBarConfig(
|
||||
enabled: false,
|
||||
),
|
||||
categoryViewConfig: CategoryViewConfig(
|
||||
backspaceColor: theme.colorScheme.primary,
|
||||
iconColor: theme.colorScheme.primary.withAlpha(
|
||||
128,
|
||||
),
|
||||
iconColorSelected: theme.colorScheme.primary,
|
||||
indicatorColor: theme.colorScheme.primary,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
),
|
||||
skinToneConfig: SkinToneConfig(
|
||||
dialogBackgroundColor: Color.lerp(
|
||||
theme.colorScheme.surface,
|
||||
theme.colorScheme.primaryContainer,
|
||||
0.75,
|
||||
)!,
|
||||
indicatorColor: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
StickerPickerDialog(
|
||||
room: controller.room,
|
||||
onSelected: (sticker) async {
|
||||
final proceed = await showTrustUserInRoomDialog(
|
||||
context,
|
||||
controller.room,
|
||||
);
|
||||
if (!proceed) return;
|
||||
controller.room.sendEvent(
|
||||
{
|
||||
'body': sticker.body,
|
||||
'info': sticker.info ?? {},
|
||||
'url': sticker.url.toString(),
|
||||
},
|
||||
type: EventTypes.Sticker,
|
||||
threadRootEventId: controller.activeThreadId,
|
||||
threadLastEventId: controller.threadLastEventId,
|
||||
);
|
||||
controller.hideEmojiPicker();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NoRecent extends StatelessWidget {
|
||||
const NoRecent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
L10n.of(context).emoteKeyboardNoRecents,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
lib/pages/chat/chat_event_list.dart
Normal file
253
lib/pages/chat/chat_event_list.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/pages/chat/encryption_info.dart';
|
||||
import 'package:tatchat/pages/chat/events/message.dart';
|
||||
import 'package:tatchat/pages/chat/seen_by_row.dart';
|
||||
import 'package:tatchat/pages/chat/typing_indicators.dart';
|
||||
import 'package:tatchat/utils/account_config.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix_api_lite/model/event_types.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import '../../utils/date_time_extension.dart';
|
||||
|
||||
class ChatEventList extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const ChatEventList({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timeline = controller.timeline;
|
||||
|
||||
if (timeline == null) {
|
||||
return const Center(child: CupertinoActivityIndicator());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final colors = [theme.secondaryBubbleColor, theme.bubbleColor];
|
||||
|
||||
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
|
||||
|
||||
final events = timeline.events.filterByVisibleInGui(
|
||||
threadId: controller.activeThreadId,
|
||||
);
|
||||
|
||||
// create a map of eventId --> index to greatly improve performance of
|
||||
// ListView's findChildIndexCallback
|
||||
final thisEventsKeyMap = <String, int>{};
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
thisEventsKeyMap[events[i].eventId] = i;
|
||||
}
|
||||
|
||||
final hasWallpaper =
|
||||
controller.room.client.applicationAccountConfig.wallpaperUrl != null;
|
||||
|
||||
return SelectionArea(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(AppSettings.fontSizeFactor.value),
|
||||
),
|
||||
child: ListView.custom(
|
||||
padding: EdgeInsets.only(
|
||||
top: 8 + MediaQuery.paddingOf(context).top,
|
||||
bottom:
|
||||
8 +
|
||||
(controller.inputBarHeight ??
|
||||
(72 + (FluffyThemes.isColumnMode(context) ? 16 : 0))),
|
||||
left: horizontalPadding,
|
||||
right: horizontalPadding,
|
||||
),
|
||||
reverse: true,
|
||||
controller: controller.scrollController,
|
||||
keyboardDismissBehavior: PlatformInfos.isIOS
|
||||
? ScrollViewKeyboardDismissBehavior.onDrag
|
||||
: ScrollViewKeyboardDismissBehavior.manual,
|
||||
childrenDelegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int i) {
|
||||
// Footer to display typing indicator and read receipts:
|
||||
if (i == 0) {
|
||||
if (timeline.canRequestFuture) {
|
||||
return Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: timeline.isRequestingFuture
|
||||
? null
|
||||
: controller.requestFuture,
|
||||
icon: timeline.isRequestingFuture
|
||||
? CircularProgressIndicator.adaptive(strokeWidth: 2)
|
||||
: const Icon(Icons.arrow_downward_outlined),
|
||||
label: Text(L10n.of(context).loadMore),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (events.isNotEmpty) SeenByRow(event: events.first),
|
||||
TypingIndicators(controller),
|
||||
EncryptionInfo(room: controller.room),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Request history button or progress indicator:
|
||||
if (i == events.length + 1) {
|
||||
if (controller.activeThreadId != null ||
|
||||
!timeline.canRequestHistory) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final visibleIndex = timeline.events.lastIndexWhere(
|
||||
(event) =>
|
||||
!event.isCollapsedState && event.isVisibleInGui,
|
||||
);
|
||||
if (visibleIndex > timeline.events.length - 50) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
controller.requestHistory,
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: timeline.isRequestingHistory
|
||||
? null
|
||||
: controller.requestHistory,
|
||||
icon: timeline.isRequestingHistory
|
||||
? CircularProgressIndicator.adaptive(strokeWidth: 2)
|
||||
: const Icon(Icons.arrow_upward_outlined),
|
||||
label: Text(L10n.of(context).loadMore),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
i--;
|
||||
|
||||
// The message at this index:
|
||||
final event = events[i];
|
||||
final animateIn =
|
||||
(event.transactionId ?? event.eventId) ==
|
||||
controller.animateInEventId;
|
||||
|
||||
final nextEvent = i + 1 < events.length ? events[i + 1] : null;
|
||||
final previousEvent = i > 0 ? events[i - 1] : null;
|
||||
|
||||
// Collapsed state event
|
||||
final canExpand =
|
||||
event.isCollapsedState &&
|
||||
nextEvent?.isCollapsedState == true &&
|
||||
previousEvent?.isCollapsedState != true;
|
||||
final isCollapsed =
|
||||
event.isCollapsedState &&
|
||||
previousEvent?.isCollapsedState == true &&
|
||||
!controller.expandedEventIds.contains(event.eventId);
|
||||
|
||||
final displayDate =
|
||||
event.type == EventTypes.RoomCreate ||
|
||||
nextEvent == null ||
|
||||
!event.originServerTs.sameDay(nextEvent.originServerTs);
|
||||
|
||||
return AutoScrollTag(
|
||||
key: ValueKey(event.transactionId ?? event.eventId),
|
||||
index: i,
|
||||
controller: controller.scrollController,
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (!isCollapsed && displayDate)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 16.0),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius * 2,
|
||||
),
|
||||
color: theme.colorScheme.inverseSurface.withAlpha(
|
||||
200,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 2.0,
|
||||
),
|
||||
child: Text(
|
||||
event.originServerTs.localizedDate(context),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Message(
|
||||
event,
|
||||
bigEmojis: controller.bigEmojis,
|
||||
animateIn: animateIn,
|
||||
onSwipe: () => controller.replyAction(replyTo: event),
|
||||
onInfoTab: controller.showEventInfo,
|
||||
onMention: () => controller.sendController.text +=
|
||||
'${event.senderFromMemoryOrFallback.mention} ',
|
||||
highlightMarker:
|
||||
controller.scrollToEventIdMarker == event.eventId,
|
||||
onSelect: controller.onSelectMessage,
|
||||
scrollToEventId: controller.scrollToEventId,
|
||||
longPressSelect: controller.selectedEvents.isNotEmpty,
|
||||
selected: controller.selectedEvents.any(
|
||||
(e) => e.eventId == event.eventId,
|
||||
),
|
||||
singleSelected:
|
||||
controller.selectedEvents.singleOrNull?.eventId ==
|
||||
event.eventId,
|
||||
onEdit: controller.editSelectedEventAction,
|
||||
timeline: timeline,
|
||||
displayReadMarker:
|
||||
i > 0 &&
|
||||
controller.readMarkerEventId == event.eventId,
|
||||
nextEvent: nextEvent,
|
||||
previousEvent: previousEvent,
|
||||
wallpaperMode: hasWallpaper,
|
||||
scrollController: controller.scrollController,
|
||||
colors: colors,
|
||||
isCollapsed: isCollapsed,
|
||||
enterThread: controller.activeThreadId == null
|
||||
? controller.enterThread
|
||||
: null,
|
||||
onExpand: canExpand
|
||||
? () => controller.expandEventsFrom(
|
||||
event,
|
||||
!controller.expandedEventIds.contains(
|
||||
event.eventId,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: events.length + 2,
|
||||
findChildIndexCallback: (key) =>
|
||||
controller.findChildIndexCallback(key, thisEventsKeyMap),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
436
lib/pages/chat/chat_input_row.dart
Normal file
436
lib/pages/chat/chat_input_row.dart
Normal file
@@ -0,0 +1,436 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:emoji_picker_flutter/locales/default_emoji_set_locale.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/recording_input_row.dart';
|
||||
import 'package:tatchat/pages/chat/recording_view_model.dart';
|
||||
import 'package:tatchat/utils/other_party_can_receive.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/hover_builder.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../config/themes.dart';
|
||||
import 'chat.dart';
|
||||
import 'input_bar.dart';
|
||||
|
||||
class ChatInputRow extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
static const double height = 56.0;
|
||||
|
||||
const ChatInputRow(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final textMessageOnly =
|
||||
controller.sendController.text.isNotEmpty ||
|
||||
controller.replyEvent != null ||
|
||||
controller.editEvent != null;
|
||||
|
||||
if (!controller.room.otherPartyCanReceiveMessages) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
L10n.of(context).otherPartyNotLoggedIn,
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final selectedTextButtonStyle = TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onTertiaryContainer,
|
||||
);
|
||||
|
||||
return RecordingViewModel(
|
||||
builder: (context, recordingViewModel) {
|
||||
if (recordingViewModel.isRecording) {
|
||||
return RecordingInputRow(
|
||||
state: recordingViewModel,
|
||||
onSend: controller.onVoiceMessageSend,
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: .end,
|
||||
mainAxisAlignment: .spaceBetween,
|
||||
children: controller.selectMode
|
||||
? <Widget>[
|
||||
if (controller.selectedEvents.every(
|
||||
(event) => event.status == EventStatus.error,
|
||||
))
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: controller.deleteErrorEventsAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(Icons.delete_forever_outlined),
|
||||
Text(L10n.of(context).delete),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: TextButton(
|
||||
style: selectedTextButtonStyle,
|
||||
onPressed: controller.forwardEventsAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(Icons.keyboard_arrow_left_outlined),
|
||||
Text(L10n.of(context).forward),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
controller.selectedEvents.length == 1
|
||||
? controller.selectedEvents.first
|
||||
.getDisplayEvent(controller.timeline!)
|
||||
.status
|
||||
.isSent
|
||||
? SizedBox(
|
||||
height: height,
|
||||
child: TextButton(
|
||||
style: selectedTextButtonStyle,
|
||||
onPressed: controller.replyAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(L10n.of(context).reply),
|
||||
const Icon(Icons.keyboard_arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: height,
|
||||
child: TextButton(
|
||||
style: selectedTextButtonStyle,
|
||||
onPressed: controller.sendAgainAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(L10n.of(context).tryToSendAgain),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.send_outlined, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
]
|
||||
: <Widget>[
|
||||
const SizedBox(width: 8),
|
||||
AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
width: textMessageOnly ? 0 : 48,
|
||||
height: height,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: PopupMenuButton<AddPopupMenuActions>(
|
||||
useRootNavigator: true,
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
iconColor: theme.colorScheme.onPrimaryContainer,
|
||||
onSelected: controller.onAddPopupMenuButtonSelected,
|
||||
itemBuilder: (BuildContext context) => [
|
||||
if (PlatformInfos.isMobile)
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.location,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.gps_fixed_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).shareLocation),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.poll,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.poll_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).startPoll),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
if (PlatformInfos.isMobile) ...[
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.videoCamera,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.videocam_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).recordAVideo),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.photoCamera,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.camera_alt_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).takeAPhoto),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
],
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.media,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.image_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).openGallery),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.file,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.attachment_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).sendFile),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: height,
|
||||
width: 48,
|
||||
alignment: Alignment.center,
|
||||
child: IconButton(
|
||||
tooltip: L10n.of(context).emojis,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
icon: Icon(
|
||||
controller.showEmojiPicker
|
||||
? Icons.keyboard
|
||||
: Icons.add_reaction_outlined,
|
||||
key: ValueKey(controller.showEmojiPicker),
|
||||
),
|
||||
onPressed: controller.emojiPickerAction,
|
||||
),
|
||||
),
|
||||
if (Matrix.of(context).isMultiAccount &&
|
||||
Matrix.of(context).hasComplexBundles &&
|
||||
Matrix.of(context).currentBundle!.length > 1)
|
||||
Container(
|
||||
height: height,
|
||||
width: 48,
|
||||
alignment: Alignment.center,
|
||||
child: _ChatAccountPicker(controller),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: InputBar(
|
||||
room: controller.room,
|
||||
minLines: 1,
|
||||
maxLines: 8,
|
||||
autofocus: !PlatformInfos.isMobile,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction:
|
||||
AppSettings.sendOnEnter.value == true &&
|
||||
PlatformInfos.isMobile
|
||||
? TextInputAction.send
|
||||
: null,
|
||||
onSubmitted: controller.onInputBarSubmitted,
|
||||
onSubmitImage: controller.sendImageFromClipBoard,
|
||||
focusNode: controller.inputFocus,
|
||||
controller: controller.sendController,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 6.0,
|
||||
right: 6.0,
|
||||
bottom: 6.0,
|
||||
top: 3.0,
|
||||
),
|
||||
counter: const SizedBox.shrink(),
|
||||
hintText: controller.room.encrypted
|
||||
? L10n.of(context).encryptedMessage
|
||||
: L10n.of(context).unencryptedMessage,
|
||||
hintMaxLines: 1,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
onChanged: controller.onInputBarChanged,
|
||||
suggestionEmojis:
|
||||
getDefaultEmojiLocale(
|
||||
AppSettings.emojiSuggestionLocale.value.isNotEmpty
|
||||
? Locale(
|
||||
AppSettings.emojiSuggestionLocale.value,
|
||||
)
|
||||
: Localizations.localeOf(context),
|
||||
).fold(
|
||||
[],
|
||||
(emojis, category) =>
|
||||
emojis..addAll(category.emoji),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: height,
|
||||
width: height,
|
||||
alignment: Alignment.center,
|
||||
child:
|
||||
PlatformInfos.platformCanRecord &&
|
||||
!controller.sendController.text.isNotEmpty &&
|
||||
controller.editEvent == null
|
||||
? HoverBuilder(
|
||||
builder: (context, hovered) => IconButton(
|
||||
tooltip: L10n.of(context).voiceMessage,
|
||||
onPressed: hovered
|
||||
? () => recordingViewModel.startRecording(
|
||||
controller.room,
|
||||
)
|
||||
: () => ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
margin: EdgeInsets.only(
|
||||
bottom: height + 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
showCloseIcon: true,
|
||||
content: Text(
|
||||
L10n.of(
|
||||
context,
|
||||
).longPressToRecordVoiceMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
onLongPress: () => recordingViewModel
|
||||
.startRecording(controller.room),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.bubbleColor,
|
||||
foregroundColor: theme.onBubbleColor,
|
||||
),
|
||||
icon: Icon(
|
||||
hovered ? Icons.mic : Icons.mic_none_outlined,
|
||||
),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
key: Key('send_button'),
|
||||
tooltip: L10n.of(context).send,
|
||||
onPressed: controller.send,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.bubbleColor,
|
||||
foregroundColor: theme.onBubbleColor,
|
||||
),
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatAccountPicker extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const _ChatAccountPicker(this.controller);
|
||||
|
||||
void _popupMenuButtonSelected(String mxid, BuildContext context) {
|
||||
final client = Matrix.of(
|
||||
context,
|
||||
).currentBundle!.firstWhere((cl) => cl!.userID == mxid, orElse: () => null);
|
||||
if (client == null) {
|
||||
Logs().w('Attempted to switch to a non-existing client $mxid');
|
||||
return;
|
||||
}
|
||||
controller.setSendingClient(client);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clients = controller.currentRoomBundle;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FutureBuilder<Profile>(
|
||||
future: controller.sendingClient.fetchOwnProfile(),
|
||||
builder: (context, snapshot) => PopupMenuButton<String>(
|
||||
useRootNavigator: true,
|
||||
onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
|
||||
itemBuilder: (BuildContext context) => clients
|
||||
.map(
|
||||
(client) => PopupMenuItem(
|
||||
value: client!.userID,
|
||||
child: FutureBuilder<Profile>(
|
||||
future: client.fetchOwnProfile(),
|
||||
builder: (context, snapshot) => ListTile(
|
||||
leading: Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name:
|
||||
snapshot.data?.displayName ??
|
||||
client.userID!.localpart,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(snapshot.data?.displayName ?? client.userID!),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name:
|
||||
snapshot.data?.displayName ??
|
||||
Matrix.of(context).client.userID!.localpart,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
494
lib/pages/chat/chat_view.dart
Normal file
494
lib/pages/chat/chat_view.dart
Normal file
@@ -0,0 +1,494 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/pages/chat/chat_app_bar_list_tile.dart';
|
||||
import 'package:tatchat/pages/chat/chat_app_bar_title.dart';
|
||||
import 'package:tatchat/pages/chat/chat_event_list.dart';
|
||||
import 'package:tatchat/pages/chat/pinned_events.dart';
|
||||
import 'package:tatchat/pages/chat/reply_display.dart';
|
||||
import 'package:tatchat/utils/account_config.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
||||
import 'package:tatchat/widgets/chat_settings_popup_menu.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:tatchat/widgets/pulsating_widget.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../utils/stream_extension.dart';
|
||||
import 'chat_emoji_picker.dart';
|
||||
import 'chat_input_row.dart';
|
||||
|
||||
enum _EventContextAction { info, report }
|
||||
|
||||
class ChatView extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const ChatView(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final hasActiveGroupCall = controller.room.hasActiveMatrixRtcCall;
|
||||
if (controller.room.membership == Membership.invite) {
|
||||
showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => controller.room.join(),
|
||||
exceptionContext: ExceptionContext.joinRoom,
|
||||
);
|
||||
}
|
||||
final bottomSheetPadding = FluffyThemes.isColumnMode(context) ? 16.0 : 8.0;
|
||||
final scrollUpBannerEventId = controller.scrollUpBannerEventId;
|
||||
|
||||
final accountConfig = Matrix.of(context).client.applicationAccountConfig;
|
||||
|
||||
return PopScope(
|
||||
canPop:
|
||||
controller.selectedEvents.isEmpty &&
|
||||
!controller.showEmojiPicker &&
|
||||
controller.activeThreadId == null,
|
||||
onPopInvokedWithResult: (pop, _) async {
|
||||
if (pop) return;
|
||||
if (controller.selectedEvents.isNotEmpty) {
|
||||
controller.clearSelectedEvents();
|
||||
} else if (controller.showEmojiPicker) {
|
||||
controller.emojiPickerAction();
|
||||
} else if (controller.activeThreadId != null) {
|
||||
controller.closeThread();
|
||||
}
|
||||
},
|
||||
child: StreamBuilder(
|
||||
stream: controller.room.client.onRoomState.stream
|
||||
.where((update) => update.roomId == controller.room.id)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, snapshot) => FutureBuilder(
|
||||
future: controller.loadTimelineFuture,
|
||||
builder: (BuildContext context, snapshot) {
|
||||
var appbarBottomHeight = 0.0;
|
||||
final activeThreadId = controller.activeThreadId;
|
||||
if (activeThreadId != null) {
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
if (controller.room.pinnedEventIds.isNotEmpty &&
|
||||
activeThreadId == null) {
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
if (scrollUpBannerEventId != null && activeThreadId == null) {
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
return Scaffold(
|
||||
key: Key('chat_page'),
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
shape: FluffyThemes.isColumnMode(context)
|
||||
? Border(
|
||||
bottom: BorderSide(color: theme.dividerColor, width: 1),
|
||||
)
|
||||
: null,
|
||||
actionsIconTheme: IconThemeData(
|
||||
color: controller.selectedEvents.isEmpty
|
||||
? null
|
||||
: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
backgroundColor: controller.selectedEvents.isEmpty
|
||||
? controller.activeThreadId != null
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: theme.colorScheme.surface.withAlpha(240)
|
||||
: theme.colorScheme.tertiaryContainer,
|
||||
automaticallyImplyLeading: false,
|
||||
leading: controller.selectMode
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: controller.clearSelectedEvents,
|
||||
tooltip: L10n.of(context).close,
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
: activeThreadId != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: controller.closeThread,
|
||||
tooltip: L10n.of(context).backToMainChat,
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
: FluffyThemes.isColumnMode(context)
|
||||
? null
|
||||
: const Center(child: BackButton()),
|
||||
titleSpacing: FluffyThemes.isColumnMode(context) ? 24 : 0,
|
||||
title: ChatAppBarTitle(controller),
|
||||
actions: [
|
||||
if (controller.selectMode) ...[
|
||||
if (controller.canEditSelectedEvents)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: L10n.of(context).edit,
|
||||
onPressed: controller.editSelectedEventAction,
|
||||
),
|
||||
if (controller.selectedEvents.length == 1 &&
|
||||
controller.activeThreadId == null &&
|
||||
controller.room.canSendDefaultMessages)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.message_outlined),
|
||||
tooltip: L10n.of(context).replyInThread,
|
||||
onPressed: () => controller.enterThread(
|
||||
controller.selectedEvents.single.eventId,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_outlined),
|
||||
tooltip: L10n.of(context).copyToClipboard,
|
||||
onPressed: controller.copyEventsAction,
|
||||
),
|
||||
if (controller.canRedactSelectedEvents)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
color: theme.colorScheme.error,
|
||||
tooltip: L10n.of(context).redactMessage,
|
||||
onPressed: controller.redactEventsAction,
|
||||
),
|
||||
if (controller.selectedEvents.length == 1)
|
||||
PopupMenuButton<_EventContextAction>(
|
||||
useRootNavigator: true,
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _EventContextAction.info:
|
||||
controller.showEventInfo();
|
||||
controller.clearSelectedEvents();
|
||||
break;
|
||||
case _EventContextAction.report:
|
||||
controller.reportEventAction();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (controller.canPinSelectedEvents)
|
||||
PopupMenuItem(
|
||||
onTap: controller.pinEvent,
|
||||
value: null,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(Icons.push_pin_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).pinMessage),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.canSaveSelectedEvent)
|
||||
PopupMenuItem(
|
||||
onTap: () =>
|
||||
controller.saveSelectedEvent(context),
|
||||
value: null,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(Icons.download_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).downloadFile),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _EventContextAction.info,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(Icons.info_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).messageInfo),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.selectedEvents.single.status.isSent)
|
||||
PopupMenuItem(
|
||||
value: _EventContextAction.report,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.shield_outlined,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).reportMessage),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (!controller.room.isArchived) ...[
|
||||
if (hasActiveGroupCall)
|
||||
PulsatingWidget(
|
||||
color: theme.colorScheme.error,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: TextButton.icon(
|
||||
icon: Icon(switch (controller
|
||||
.room
|
||||
.activeMatrixRtcCallIntent) {
|
||||
.voice => Icons.video_call_outlined,
|
||||
_ => Icons.mic_outlined,
|
||||
}),
|
||||
label: Text(
|
||||
controller.room
|
||||
.getActiveMatrixRtcMembers()
|
||||
.length
|
||||
.toString(),
|
||||
),
|
||||
onPressed:
|
||||
controller.room.hasPermissionForMatrixRtcCall
|
||||
? controller.startOrJoinVideoCall
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (controller.room.hasPermissionForMatrixRtcCall &&
|
||||
controller.supportLiveKitCalls)
|
||||
IconButton(
|
||||
icon: Icon(Icons.call_outlined),
|
||||
onPressed: controller.startOrJoinVideoCall,
|
||||
),
|
||||
ChatSettingsPopupMenu(controller.room, true),
|
||||
],
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(appbarBottomHeight),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
PinnedEvents(controller),
|
||||
if (activeThreadId != null)
|
||||
SizedBox(
|
||||
height: ChatAppBarListTile.fixedHeight,
|
||||
child: Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
controller.scrollToEventId(activeThreadId),
|
||||
icon: const Icon(Icons.message),
|
||||
label: Text(L10n.of(context).replyInThread),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
theme.colorScheme.onSecondaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (scrollUpBannerEventId != null &&
|
||||
activeThreadId == null)
|
||||
ChatAppBarListTile(
|
||||
leading: IconButton(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: L10n.of(context).close,
|
||||
onPressed: () {
|
||||
controller.discardScrollUpBannerEventId();
|
||||
controller.setReadMarker();
|
||||
},
|
||||
),
|
||||
title: L10n.of(context).jumpToLastReadMessage,
|
||||
trailing: TextButton(
|
||||
onPressed: () {
|
||||
controller.scrollToEventId(scrollUpBannerEventId);
|
||||
controller.discardScrollUpBannerEventId();
|
||||
},
|
||||
child: Text(L10n.of(context).jump),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton:
|
||||
controller.showScrollDownButton &&
|
||||
controller.selectedEvents.isEmpty
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: theme.appBarTheme.toolbarHeight ?? 56,
|
||||
),
|
||||
child: FloatingActionButton(
|
||||
onPressed: controller.scrollDown,
|
||||
heroTag: null,
|
||||
mini: true,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
foregroundColor: theme.colorScheme.onSurface,
|
||||
child: const Icon(Icons.arrow_downward_outlined),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: NotificationListener<SizeChangedLayoutNotification>(
|
||||
onNotification: (SizeChangedLayoutNotification notification) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => controller.updateInputBarHeight(),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
child: DropTarget(
|
||||
onDragDone: controller.onDragDone,
|
||||
onDragEntered: controller.onDragEntered,
|
||||
onDragExited: controller.onDragExited,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
if (accountConfig.wallpaperUrl != null)
|
||||
Opacity(
|
||||
opacity: accountConfig.wallpaperOpacity ?? 0.5,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ui.ImageFilter.blur(
|
||||
sigmaX: accountConfig.wallpaperBlur ?? 0.0,
|
||||
sigmaY: accountConfig.wallpaperBlur ?? 0.0,
|
||||
),
|
||||
child: MxcImage(
|
||||
cacheKey: accountConfig.wallpaperUrl.toString(),
|
||||
uri: accountConfig.wallpaperUrl,
|
||||
fit: BoxFit.cover,
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
isThumbnail: false,
|
||||
placeholder: (_) => Container(),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: controller.clearSingleSelectedEvent,
|
||||
child: ChatEventList(controller: controller),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SizeChangedLayoutNotifier(
|
||||
child: Container(
|
||||
key: controller.inputBarKey,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
theme.colorScheme.surface.withAlpha(0),
|
||||
theme.colorScheme.surface,
|
||||
theme.colorScheme.surface,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
margin: EdgeInsets.all(bottomSheetPadding),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.maxTimelineWidth,
|
||||
),
|
||||
child: controller.room.isExtinct
|
||||
? ElevatedButton.icon(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
label: Text(
|
||||
L10n.of(context).enterNewChat,
|
||||
),
|
||||
onPressed: controller.goToNewRoomAction,
|
||||
)
|
||||
: controller.room.canSendDefaultMessages &&
|
||||
controller.room.membership ==
|
||||
Membership.join
|
||||
? Material(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
color:
|
||||
controller.selectedEvents.isNotEmpty
|
||||
? theme
|
||||
.colorScheme
|
||||
.tertiaryContainer
|
||||
: theme
|
||||
.colorScheme
|
||||
.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child:
|
||||
controller.room.isAbandonedDMRoom ==
|
||||
true
|
||||
? Row(
|
||||
mainAxisAlignment: .spaceEvenly,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.all(
|
||||
16,
|
||||
),
|
||||
foregroundColor: theme
|
||||
.colorScheme
|
||||
.error,
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.archive_outlined,
|
||||
),
|
||||
onPressed:
|
||||
controller.leaveChat,
|
||||
label: Text(
|
||||
L10n.of(context).leave,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.all(
|
||||
16,
|
||||
),
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.forum_outlined,
|
||||
),
|
||||
onPressed:
|
||||
controller.recreateChat,
|
||||
label: Text(
|
||||
L10n.of(
|
||||
context,
|
||||
).reopenChat,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
ReplyDisplay(controller),
|
||||
ChatInputRow(controller),
|
||||
ChatEmojiPicker(controller),
|
||||
],
|
||||
),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.dragging)
|
||||
Container(
|
||||
color: theme.scaffoldBackgroundColor.withAlpha(230),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(Icons.upload_outlined, size: 100),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
lib/pages/chat/command_hints.dart
Normal file
95
lib/pages/chat/command_hints.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// This file is auto-generated using scripts/generate_command_hints_glue.sh.
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
|
||||
String commandExample(String command) {
|
||||
switch (command) {
|
||||
case 'markasdm':
|
||||
case 'kick':
|
||||
case 'dm':
|
||||
case 'ban':
|
||||
case 'unban':
|
||||
case 'ignore':
|
||||
case 'unignore':
|
||||
case 'invite':
|
||||
return '/$command <matrix-id>';
|
||||
case 'html':
|
||||
case 'sendraw':
|
||||
case 'plain':
|
||||
return '/$command <message>';
|
||||
case 'op':
|
||||
return '/$command <matrix-id> <power-level>';
|
||||
default:
|
||||
return '/$command';
|
||||
}
|
||||
}
|
||||
|
||||
String commandHint(L10n l10n, String command) {
|
||||
switch (command) {
|
||||
case 'ban':
|
||||
return l10n.commandHint_ban;
|
||||
case 'clearcache':
|
||||
return l10n.commandHint_clearcache;
|
||||
case 'create':
|
||||
return l10n.commandHint_create;
|
||||
case 'discardsession':
|
||||
return l10n.commandHint_discardsession;
|
||||
case 'dm':
|
||||
return l10n.commandHint_dm;
|
||||
case 'html':
|
||||
return l10n.commandHint_html;
|
||||
case 'invite':
|
||||
return l10n.commandHint_invite;
|
||||
case 'join':
|
||||
return l10n.commandHint_join;
|
||||
case 'kick':
|
||||
return l10n.commandHint_kick;
|
||||
case 'leave':
|
||||
return l10n.commandHint_leave;
|
||||
case 'me':
|
||||
return l10n.commandHint_me;
|
||||
case 'myroomavatar':
|
||||
return l10n.commandHint_myroomavatar;
|
||||
case 'myroomnick':
|
||||
return l10n.commandHint_myroomnick;
|
||||
case 'op':
|
||||
return l10n.commandHint_op;
|
||||
case 'plain':
|
||||
return l10n.commandHint_plain;
|
||||
case 'react':
|
||||
return l10n.commandHint_react;
|
||||
case 'send':
|
||||
return l10n.commandHint_send;
|
||||
case 'unban':
|
||||
return l10n.commandHint_unban;
|
||||
case 'markasdm':
|
||||
return l10n.commandHint_markasdm;
|
||||
case 'markasgroup':
|
||||
return l10n.commandHint_markasgroup;
|
||||
case 'googly':
|
||||
return l10n.commandHint_googly;
|
||||
case 'hug':
|
||||
return l10n.commandHint_hug;
|
||||
case 'cuddle':
|
||||
return l10n.commandHint_cuddle;
|
||||
case 'sendraw':
|
||||
return l10n.commandHint_sendraw;
|
||||
case 'ignore':
|
||||
return l10n.commandHint_ignore;
|
||||
case 'unignore':
|
||||
return l10n.commandHint_unignore;
|
||||
case 'roomupgrade':
|
||||
return l10n.commandHint_roomupgrade;
|
||||
case 'logout':
|
||||
return l10n.commandHint_logout;
|
||||
case 'logoutall':
|
||||
return l10n.commandHint_logoutall;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
107
lib/pages/chat/encryption_info.dart
Normal file
107
lib/pages/chat/encryption_info.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class EncryptionInfo extends StatelessWidget {
|
||||
final Room room;
|
||||
|
||||
const EncryptionInfo({super.key, required this.room});
|
||||
|
||||
Future<int> _unverifiedDevices() async {
|
||||
if (!room.encrypted) return 0;
|
||||
final users = await room.requestParticipants();
|
||||
final devicesKeysLists = users
|
||||
.map((user) => room.client.userDeviceKeys[user.id])
|
||||
.nonNulls;
|
||||
final devices = devicesKeysLists.fold<List<DeviceKeys>>(
|
||||
[],
|
||||
(devices, devicesKeysList) => [
|
||||
...devices,
|
||||
...devicesKeysList.deviceKeys.values,
|
||||
],
|
||||
);
|
||||
return devices
|
||||
.where(
|
||||
(device) =>
|
||||
!device.verified &&
|
||||
!device.blocked &&
|
||||
!device.hasValidSignatureChain(verifiedByTheirMasterKey: true) &&
|
||||
device.encryptToDevice,
|
||||
)
|
||||
.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: FutureBuilder(
|
||||
future: _unverifiedDevices(),
|
||||
builder: (context, asyncSnapshot) {
|
||||
final unverifiedDevices = asyncSnapshot.data ?? 0;
|
||||
if (unverifiedDevices == 0) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface.withAlpha(128),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius / 3,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: Icon(Icons.lock_person_outlined, size: 13),
|
||||
),
|
||||
TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: L10n.of(
|
||||
context,
|
||||
).countUnverifiedDevices(unverifiedDevices),
|
||||
),
|
||||
TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: theme.colorScheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () =>
|
||||
context.go('/rooms/${room.id}/encryption'),
|
||||
text: 'Check',
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/pages/chat/event_info_dialog.dart
Normal file
108
lib/pages/chat/event_info_dialog.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:tatchat/utils/date_time_extension.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
extension EventInfoDialogExtension on Event {
|
||||
void showInfoDialog(BuildContext context) => showAdaptiveBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => EventInfoDialog(l10n: L10n.of(context), event: this),
|
||||
);
|
||||
}
|
||||
|
||||
class EventInfoDialog extends StatelessWidget {
|
||||
final Event event;
|
||||
final L10n l10n;
|
||||
|
||||
const EventInfoDialog({required this.event, required this.l10n, super.key});
|
||||
|
||||
String prettyJson(MatrixEvent event) {
|
||||
const decoder = JsonDecoder();
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
final object = decoder.convert(jsonEncode(event.toJson()));
|
||||
return encoder.convert(object);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final originalSource = event.originalSource;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(L10n.of(context).messageInfo),
|
||||
leading: CloseButton(
|
||||
onPressed: Navigator.of(context, rootNavigator: false).pop,
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Avatar(
|
||||
mxContent: event.senderFromMemoryOrFallback.avatarUrl,
|
||||
name: event.senderFromMemoryOrFallback.calcDisplayname(),
|
||||
client: event.room.client,
|
||||
presenceUserId: event.senderId,
|
||||
),
|
||||
title: Text(L10n.of(context).sender),
|
||||
subtitle: Text(
|
||||
'${event.senderFromMemoryOrFallback.calcDisplayname()} [${event.senderId}]',
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('${L10n.of(context).time}:'),
|
||||
subtitle: Text(event.originServerTs.localizedTime(context)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('${L10n.of(context).status}:'),
|
||||
subtitle: Text(event.status.name),
|
||||
),
|
||||
ListTile(title: Text('${L10n.of(context).sourceCode}:')),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SelectableText(
|
||||
prettyJson(MatrixEvent.fromJson(event.toJson())),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (event.messageType != MessageTypes.BadEncrypted &&
|
||||
originalSource != null) ...[
|
||||
ListTile(title: Text('${L10n.of(context).encrypted}:')),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SelectableText(
|
||||
prettyJson(originalSource),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
538
lib/pages/chat/events/audio_player.dart
Normal file
538
lib/pages/chat/events/audio_player.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/utils/error_reporter.dart';
|
||||
import 'package:tatchat/utils/file_description.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:ogg_caf_converter/ogg_caf_converter.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../../utils/matrix_sdk_extensions/event_extension.dart';
|
||||
import '../../../widgets/tatu_chat_app.dart';
|
||||
import '../../../widgets/matrix.dart';
|
||||
|
||||
class AudioPlayerWidget extends StatefulWidget {
|
||||
final Color color;
|
||||
final Color linkColor;
|
||||
final double fontSize;
|
||||
final Event event;
|
||||
|
||||
static const int wavesCount = 40;
|
||||
|
||||
const AudioPlayerWidget(
|
||||
this.event, {
|
||||
required this.color,
|
||||
required this.linkColor,
|
||||
required this.fontSize,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
AudioPlayerState createState() => AudioPlayerState();
|
||||
}
|
||||
|
||||
enum AudioPlayerStatus { notDownloaded, downloading, downloaded }
|
||||
|
||||
class AudioPlayerState extends State<AudioPlayerWidget> {
|
||||
static const double buttonSize = 36;
|
||||
|
||||
AudioPlayerStatus status = AudioPlayerStatus.notDownloaded;
|
||||
double? _downloadProgress;
|
||||
|
||||
late final MatrixState matrix;
|
||||
List<int>? _waveform;
|
||||
String? _durationString;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
final audioPlayer = matrix.voiceMessageEventId.value != widget.event.eventId
|
||||
? null
|
||||
: matrix.audioPlayer;
|
||||
if (audioPlayer != null) {
|
||||
if (audioPlayer.playing && !audioPlayer.isAtEndPosition) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(matrix.context).showMaterialBanner(
|
||||
MaterialBanner(
|
||||
padding: EdgeInsets.zero,
|
||||
leading: StreamBuilder(
|
||||
stream: audioPlayer.playerStateStream.asBroadcastStream(),
|
||||
builder: (context, _) => IconButton(
|
||||
onPressed: () {
|
||||
if (audioPlayer.isAtEndPosition) {
|
||||
audioPlayer.seek(Duration.zero);
|
||||
} else if (audioPlayer.playing) {
|
||||
audioPlayer.pause();
|
||||
} else {
|
||||
audioPlayer.play();
|
||||
}
|
||||
},
|
||||
icon: audioPlayer.playing && !audioPlayer.isAtEndPosition
|
||||
? const Icon(Icons.pause_outlined)
|
||||
: const Icon(Icons.play_arrow_outlined),
|
||||
),
|
||||
),
|
||||
content: StreamBuilder(
|
||||
stream: audioPlayer.positionStream.asBroadcastStream(),
|
||||
builder: (context, _) => GestureDetector(
|
||||
onTap: () => TatuChatApp.router.go(
|
||||
'/rooms/${widget.event.room.id}?event=${widget.event.eventId}',
|
||||
),
|
||||
child: Text(
|
||||
'🎙️ ${audioPlayer.position.minuteSecondString} / ${audioPlayer.duration?.minuteSecondString} - ${widget.event.senderFromMemoryOrFallback.calcDisplayname()}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
audioPlayer.pause();
|
||||
audioPlayer.dispose();
|
||||
matrix.voiceMessageEventId.value = matrix.audioPlayer =
|
||||
null;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(
|
||||
matrix.context,
|
||||
).clearMaterialBanners();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
audioPlayer.pause();
|
||||
audioPlayer.dispose();
|
||||
matrix.voiceMessageEventId.value = matrix.audioPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onButtonTap() async {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(matrix.context).clearMaterialBanners();
|
||||
});
|
||||
final currentPlayer =
|
||||
matrix.voiceMessageEventId.value != widget.event.eventId
|
||||
? null
|
||||
: matrix.audioPlayer;
|
||||
if (currentPlayer != null && !currentPlayer.isAtEndPosition) {
|
||||
if (currentPlayer.playing) {
|
||||
currentPlayer.pause();
|
||||
} else {
|
||||
currentPlayer.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
matrix.voiceMessageEventId.value = widget.event.eventId;
|
||||
matrix.audioPlayer
|
||||
?..stop()
|
||||
..dispose();
|
||||
File? file;
|
||||
MatrixFile? matrixFile;
|
||||
|
||||
setState(() => status = AudioPlayerStatus.downloading);
|
||||
try {
|
||||
final fileSize = widget.event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<int>('size');
|
||||
matrixFile = await widget.event.downloadAndDecryptAttachment(
|
||||
onDownloadProgress: fileSize != null && fileSize > 0
|
||||
? (progress) {
|
||||
final progressPercentage = progress / fileSize;
|
||||
setState(() {
|
||||
_downloadProgress = progressPercentage < 1
|
||||
? progressPercentage
|
||||
: null;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
final attachmentUrl = widget.event.attachmentOrThumbnailMxcUrl();
|
||||
|
||||
if (!kIsWeb && attachmentUrl != null) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = Uri.encodeComponent(attachmentUrl.pathSegments.last);
|
||||
file = File('${tempDir.path}/${fileName}_${matrixFile.name}');
|
||||
|
||||
await file.writeAsBytes(matrixFile.bytes);
|
||||
|
||||
if (Platform.isIOS &&
|
||||
matrixFile.mimeType.toLowerCase() == 'audio/ogg') {
|
||||
Logs().v('Convert ogg audio file for iOS...');
|
||||
final convertedFile = File('${file.path}.caf');
|
||||
if (await convertedFile.exists() == false) {
|
||||
await OggCafConverter().convertOggToCaf(
|
||||
input: file.path,
|
||||
output: convertedFile.path,
|
||||
);
|
||||
}
|
||||
file = convertedFile;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
status = AudioPlayerStatus.downloaded;
|
||||
});
|
||||
} catch (e, s) {
|
||||
Logs().v('Could not download audio file', e, s);
|
||||
if (!mounted) rethrow;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
|
||||
rethrow;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (matrix.voiceMessageEventId.value != widget.event.eventId) return;
|
||||
|
||||
final audioPlayer = matrix.audioPlayer = AudioPlayer();
|
||||
|
||||
if (file != null) {
|
||||
audioPlayer.setFilePath(file.path);
|
||||
} else {
|
||||
await audioPlayer.setAudioSource(
|
||||
AudioSource.uri(
|
||||
Uri.dataFromBytes(matrixFile.bytes, mimeType: matrixFile.mimeType),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
audioPlayer.play().onError(
|
||||
ErrorReporter(context, 'Unable to play audio message').onErrorCallback,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleSpeed() async {
|
||||
final audioPlayer = matrix.audioPlayer;
|
||||
if (audioPlayer == null) return;
|
||||
switch (audioPlayer.speed) {
|
||||
case 1.0:
|
||||
await audioPlayer.setSpeed(1.25);
|
||||
break;
|
||||
case 1.25:
|
||||
await audioPlayer.setSpeed(1.5);
|
||||
break;
|
||||
case 1.5:
|
||||
await audioPlayer.setSpeed(2.0);
|
||||
break;
|
||||
case 2.0:
|
||||
await audioPlayer.setSpeed(0.5);
|
||||
break;
|
||||
case 0.5:
|
||||
default:
|
||||
await audioPlayer.setSpeed(1.0);
|
||||
break;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
List<int>? _getWaveform() {
|
||||
final eventWaveForm = widget.event.content
|
||||
.tryGetMap<String, Object?>('org.matrix.msc1767.audio')
|
||||
?.tryGetList<int>('waveform');
|
||||
if (eventWaveForm == null || eventWaveForm.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
while (eventWaveForm.length < AudioPlayerWidget.wavesCount) {
|
||||
for (var i = 0; i < eventWaveForm.length; i = i + 2) {
|
||||
eventWaveForm.insert(i, eventWaveForm[i]);
|
||||
}
|
||||
}
|
||||
var i = 0;
|
||||
final step = (eventWaveForm.length / AudioPlayerWidget.wavesCount).round();
|
||||
while (eventWaveForm.length > AudioPlayerWidget.wavesCount) {
|
||||
eventWaveForm.removeAt(i);
|
||||
i = (i + step) % AudioPlayerWidget.wavesCount;
|
||||
}
|
||||
return eventWaveForm.map((i) => i > 1024 ? 1024 : i).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
matrix = Matrix.of(context);
|
||||
_waveform = _getWaveform();
|
||||
|
||||
if (matrix.voiceMessageEventId.value == widget.event.eventId &&
|
||||
matrix.audioPlayer != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(matrix.context).clearMaterialBanners();
|
||||
});
|
||||
}
|
||||
|
||||
final durationInt = widget.event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<int>('duration');
|
||||
if (durationInt != null) {
|
||||
final duration = Duration(milliseconds: durationInt);
|
||||
_durationString = duration.minuteSecondString;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final waveform = _waveform;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: matrix.voiceMessageEventId,
|
||||
builder: (context, eventId, _) {
|
||||
final audioPlayer = eventId != widget.event.eventId
|
||||
? null
|
||||
: matrix.audioPlayer;
|
||||
|
||||
final fileDescription = widget.event.fileDescription;
|
||||
|
||||
return StreamBuilder<Object>(
|
||||
stream: audioPlayer == null
|
||||
? null
|
||||
: StreamGroup.merge([
|
||||
audioPlayer.positionStream.asBroadcastStream(),
|
||||
audioPlayer.playerStateStream.asBroadcastStream(),
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final maxPosition =
|
||||
audioPlayer?.duration?.inMilliseconds.toDouble() ?? 1.0;
|
||||
var currentPosition =
|
||||
audioPlayer?.position.inMilliseconds.toDouble() ?? 0.0;
|
||||
if (currentPosition > maxPosition) currentPosition = maxPosition;
|
||||
|
||||
final wavePosition =
|
||||
(currentPosition / maxPosition) * AudioPlayerWidget.wavesCount;
|
||||
|
||||
final statusText = audioPlayer == null
|
||||
? _durationString ?? '00:00'
|
||||
: audioPlayer.position.minuteSecondString;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.columnWidth,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
child: status == AudioPlayerStatus.downloading
|
||||
? CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: widget.color,
|
||||
value: _downloadProgress,
|
||||
)
|
||||
: InkWell(
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
onLongPress: () =>
|
||||
widget.event.saveFile(context),
|
||||
onTap: _onButtonTap,
|
||||
child: Material(
|
||||
color: widget.color.withAlpha(64),
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
child: Icon(
|
||||
audioPlayer?.playing == true &&
|
||||
audioPlayer?.isAtEndPosition ==
|
||||
false
|
||||
? Icons.pause_outlined
|
||||
: Icons.play_arrow_outlined,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (waveform != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (
|
||||
var i = 0;
|
||||
i < AudioPlayerWidget.wavesCount;
|
||||
i++
|
||||
)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
margin:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: i < wavePosition
|
||||
? widget.color
|
||||
: widget.color.withAlpha(
|
||||
128,
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(64),
|
||||
),
|
||||
height: 32 * (waveform[i] / 1024),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: Slider(
|
||||
thumbColor:
|
||||
widget.event.senderId ==
|
||||
widget.event.room.client.userID
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.primary,
|
||||
activeColor: waveform == null
|
||||
? widget.color
|
||||
: Colors.transparent,
|
||||
inactiveColor: waveform == null
|
||||
? widget.color.withAlpha(128)
|
||||
: Colors.transparent,
|
||||
max: maxPosition,
|
||||
value: currentPosition,
|
||||
onChanged: (position) => audioPlayer == null
|
||||
? _onButtonTap()
|
||||
: audioPlayer.seek(
|
||||
Duration(
|
||||
milliseconds: position.round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(color: widget.color, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Icon(
|
||||
Icons.mic_none_outlined,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
secondChild: Material(
|
||||
color: widget.color.withAlpha(64),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
onTap: _toggleSpeed,
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 20,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${audioPlayer?.speed}x',
|
||||
style: TextStyle(
|
||||
color: widget.color,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
crossFadeState: audioPlayer == null
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (fileDescription != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Linkify(
|
||||
text: fileDescription,
|
||||
textScaleFactor: MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(1),
|
||||
style: TextStyle(
|
||||
color: widget.color,
|
||||
fontSize: widget.fontSize,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: widget.linkColor,
|
||||
fontSize: widget.fontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: widget.linkColor,
|
||||
),
|
||||
onOpen: (url) =>
|
||||
UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on AudioPlayer {
|
||||
bool get isAtEndPosition {
|
||||
final duration = this.duration;
|
||||
if (duration == null) return true;
|
||||
return position >= duration;
|
||||
}
|
||||
}
|
||||
|
||||
extension on Duration {
|
||||
String get minuteSecondString =>
|
||||
'${inMinutes.toString().padLeft(2, '0')}:${(inSeconds % 60).toString().padLeft(2, '0')}';
|
||||
}
|
||||
195
lib/pages/chat/events/cute_events.dart
Normal file
195
lib/pages/chat/events/cute_events.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class CuteContent extends StatefulWidget {
|
||||
final Event event;
|
||||
|
||||
const CuteContent(this.event, {super.key});
|
||||
|
||||
@override
|
||||
State<CuteContent> createState() => _CuteContentState();
|
||||
}
|
||||
|
||||
class _CuteContentState extends State<CuteContent> {
|
||||
static bool _isOverlayShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (AppSettings.autoplayImages.value && !_isOverlayShown) {
|
||||
addOverlay();
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<User?>(
|
||||
future: widget.event.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
final label = generateLabel(snapshot.data);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: addOverlay,
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
Text(widget.event.text, style: const TextStyle(fontSize: 150)),
|
||||
if (label != null) Text(label),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addOverlay() async {
|
||||
_isOverlayShown = true;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (!mounted) return;
|
||||
|
||||
OverlayEntry? overlay;
|
||||
overlay = OverlayEntry(
|
||||
builder: (context) => CuteEventOverlay(
|
||||
emoji: widget.event.text,
|
||||
onAnimationEnd: () {
|
||||
_isOverlayShown = false;
|
||||
overlay?.remove();
|
||||
},
|
||||
),
|
||||
);
|
||||
Overlay.of(context).insert(overlay);
|
||||
}
|
||||
|
||||
String? generateLabel(User? user) {
|
||||
switch (widget.event.content['cute_type']) {
|
||||
case 'googly_eyes':
|
||||
return L10n.of(context).googlyEyesContent(
|
||||
user?.displayName ??
|
||||
widget.event.senderFromMemoryOrFallback.displayName ??
|
||||
'',
|
||||
);
|
||||
case 'cuddle':
|
||||
return L10n.of(context).cuddleContent(
|
||||
user?.displayName ??
|
||||
widget.event.senderFromMemoryOrFallback.displayName ??
|
||||
'',
|
||||
);
|
||||
case 'hug':
|
||||
return L10n.of(context).hugContent(
|
||||
user?.displayName ??
|
||||
widget.event.senderFromMemoryOrFallback.displayName ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class CuteEventOverlay extends StatefulWidget {
|
||||
final String emoji;
|
||||
final VoidCallback onAnimationEnd;
|
||||
|
||||
const CuteEventOverlay({
|
||||
super.key,
|
||||
required this.emoji,
|
||||
required this.onAnimationEnd,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CuteEventOverlay> createState() => _CuteEventOverlayState();
|
||||
}
|
||||
|
||||
class _CuteEventOverlayState extends State<CuteEventOverlay>
|
||||
with TickerProviderStateMixin {
|
||||
final List<Size> items = List.generate(
|
||||
50,
|
||||
(index) => Size(Random().nextDouble(), 4 + (Random().nextDouble() * 4)),
|
||||
);
|
||||
|
||||
AnimationController? controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 2500),
|
||||
vsync: this,
|
||||
);
|
||||
controller?.forward();
|
||||
controller?.addStatusListener(_hideOverlay);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: controller!,
|
||||
builder: (context, _) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth - _CuteOverlayContent.size;
|
||||
final height = constraints.maxHeight + _CuteOverlayContent.size;
|
||||
return SizedBox(
|
||||
height: constraints.maxHeight,
|
||||
width: constraints.maxWidth,
|
||||
child: OverflowBox(
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomLeft,
|
||||
fit: StackFit.expand,
|
||||
children: items
|
||||
.map(
|
||||
(position) => Positioned(
|
||||
left: position.width * width,
|
||||
bottom:
|
||||
(height *
|
||||
.25 *
|
||||
position.height *
|
||||
(controller?.value ?? 0)) -
|
||||
_CuteOverlayContent.size,
|
||||
child: _CuteOverlayContent(emoji: widget.emoji),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _hideOverlay(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
widget.onAnimationEnd.call();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _CuteOverlayContent extends StatelessWidget {
|
||||
static const double size = 64.0;
|
||||
final String emoji;
|
||||
|
||||
const _CuteOverlayContent({required this.emoji});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 48)),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/pages/chat/events/file_send_status_indicator.dart
Normal file
35
lib/pages/chat/events/file_send_status_indicator.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class FileSendStatusIndicator extends StatelessWidget {
|
||||
final FileSendingStatus fileSendingStatus;
|
||||
const FileSendStatusIndicator({super.key, required this.fileSendingStatus});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CircleAvatar(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
switch (fileSendingStatus) {
|
||||
FileSendingStatus.generatingThumbnail => Icon(
|
||||
Icons.compress_outlined,
|
||||
size: 18,
|
||||
),
|
||||
FileSendingStatus.encrypting => Icon(Icons.lock_outlined, size: 18),
|
||||
FileSendingStatus.uploading => Icon(
|
||||
Icons.upload_outlined,
|
||||
size: 18,
|
||||
),
|
||||
},
|
||||
const CircularProgressIndicator(strokeWidth: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
743
lib/pages/chat/events/html_message.dart
Normal file
743
lib/pages/chat/events/html_message.dart
Normal file
@@ -0,0 +1,743 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/code_highlight_theme.dart';
|
||||
import 'package:tatchat/utils/event_checkbox_extension.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:highlight/highlight.dart' show highlight;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as parser;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../utils/url_launcher.dart';
|
||||
|
||||
class HtmlMessage extends StatelessWidget {
|
||||
final String html;
|
||||
final Room room;
|
||||
final Color textColor;
|
||||
final double fontSize;
|
||||
final TextStyle linkStyle;
|
||||
final void Function(LinkableElement) onOpen;
|
||||
final String? eventId;
|
||||
final Set<Event>? checkboxCheckedEvents;
|
||||
|
||||
const HtmlMessage({
|
||||
super.key,
|
||||
required this.html,
|
||||
required this.room,
|
||||
required this.fontSize,
|
||||
required this.linkStyle,
|
||||
this.textColor = Colors.black,
|
||||
required this.onOpen,
|
||||
this.eventId,
|
||||
this.checkboxCheckedEvents,
|
||||
});
|
||||
|
||||
/// Keep in sync with: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes
|
||||
static const Set<String> allowedHtmlTags = {
|
||||
'font',
|
||||
'del',
|
||||
's',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'p',
|
||||
'a',
|
||||
'ul',
|
||||
'ol',
|
||||
'sup',
|
||||
'sub',
|
||||
'li',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'strong',
|
||||
'em',
|
||||
'strike',
|
||||
'code',
|
||||
'hr',
|
||||
'br',
|
||||
'div',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
'caption',
|
||||
'pre',
|
||||
'span',
|
||||
'img',
|
||||
'details',
|
||||
'summary',
|
||||
// Not in the allowlist of the matrix spec yet but should be harmless:
|
||||
'ruby',
|
||||
'rp',
|
||||
'rt',
|
||||
'html',
|
||||
'body',
|
||||
};
|
||||
|
||||
static const Set<String> ignoredHtmlTags = {'mx-reply'};
|
||||
|
||||
/// We add line breaks before these tags:
|
||||
static const Set<String> blockHtmlTags = {
|
||||
'p',
|
||||
'ul',
|
||||
'ol',
|
||||
'pre',
|
||||
'div',
|
||||
'table',
|
||||
'details',
|
||||
'blockquote',
|
||||
};
|
||||
|
||||
/// We add line breaks before these tags:
|
||||
static const Set<String> fullLineHtmlTag = {
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'li',
|
||||
};
|
||||
|
||||
/// Adding line breaks before block elements.
|
||||
List<InlineSpan> _renderWithLineBreaks(
|
||||
dom.NodeList nodes,
|
||||
BuildContext context, {
|
||||
int depth = 1,
|
||||
}) {
|
||||
final onlyElements = nodes.whereType<dom.Element>().toList();
|
||||
return [
|
||||
for (var i = 0; i < nodes.length; i++) ...[
|
||||
// Actually render the node child:
|
||||
_renderHtml(nodes[i], context, depth: depth + 1),
|
||||
// Add linebreaks between blocks:
|
||||
if (nodes[i] is dom.Element &&
|
||||
onlyElements.indexOf(nodes[i] as dom.Element) <
|
||||
onlyElements.length - 1) ...[
|
||||
if (blockHtmlTags.contains((nodes[i] as dom.Element).localName))
|
||||
const TextSpan(text: '\n\n'),
|
||||
if (fullLineHtmlTag.contains((nodes[i] as dom.Element).localName))
|
||||
const TextSpan(text: '\n'),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
InlineSpan _renderCodeBlockNode(dom.Node node) {
|
||||
if (node is! dom.Element) {
|
||||
return TextSpan(text: node.text);
|
||||
}
|
||||
final style =
|
||||
atomOneDarkTheme[node.className.split('-').last] ??
|
||||
atomOneDarkTheme['root'];
|
||||
|
||||
return TextSpan(
|
||||
children: node.nodes.map(_renderCodeBlockNode).toList(),
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
|
||||
/// Transforms a Node to an InlineSpan.
|
||||
InlineSpan _renderHtml(dom.Node node, BuildContext context, {int depth = 1}) {
|
||||
// We must not render elements nested more than 100 elements deep:
|
||||
if (depth >= 100) return const TextSpan();
|
||||
|
||||
if (node is dom.Element &&
|
||||
ignoredHtmlTags.contains(node.localName?.toLowerCase())) {
|
||||
return const TextSpan();
|
||||
}
|
||||
|
||||
// This is a text node or not permitted node, so we render it as text:
|
||||
if (node is! dom.Element || !allowedHtmlTags.contains(node.localName)) {
|
||||
var text = node.text ?? '';
|
||||
// Single linebreak nodes between Elements are ignored:
|
||||
if (text == '\n') text = '';
|
||||
|
||||
return LinkifySpan(
|
||||
text: text,
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: linkStyle,
|
||||
onOpen: onOpen,
|
||||
);
|
||||
}
|
||||
|
||||
switch (node.localName) {
|
||||
case 'br':
|
||||
return const TextSpan(text: '\n');
|
||||
case 'a':
|
||||
final href = node.attributes['href'];
|
||||
if (href == null) continue block;
|
||||
final matrixId = node.attributes['href']
|
||||
?.parseIdentifierIntoParts()
|
||||
?.primaryIdentifier;
|
||||
if (matrixId != null) {
|
||||
if (matrixId.sigil == '@') {
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback(matrixId);
|
||||
return WidgetSpan(
|
||||
child: MatrixPill(
|
||||
key: Key('user_pill_$matrixId'),
|
||||
name: user.calcDisplayname(),
|
||||
avatar: user.avatarUrl,
|
||||
uri: href,
|
||||
outerContext: context,
|
||||
fontSize: fontSize,
|
||||
color: linkStyle.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (matrixId.sigil == '#' || matrixId.sigil == '!') {
|
||||
final room = matrixId.sigil == '!'
|
||||
? this.room.client.getRoomById(matrixId)
|
||||
: this.room.client.getRoomByAlias(matrixId);
|
||||
return WidgetSpan(
|
||||
child: MatrixPill(
|
||||
name: room?.getLocalizedDisplayname() ?? matrixId,
|
||||
avatar: room?.avatar,
|
||||
uri: href,
|
||||
outerContext: context,
|
||||
fontSize: fontSize,
|
||||
color: linkStyle.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return WidgetSpan(
|
||||
child: Tooltip(
|
||||
message: href,
|
||||
child: InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: () => UrlLauncher(context, href, node.text).launchUrl(),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _renderWithLineBreaks(
|
||||
node.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
style: linkStyle,
|
||||
),
|
||||
style: const TextStyle(height: 1.25),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'li':
|
||||
if (!{'ol', 'ul'}.contains(node.parent?.localName)) {
|
||||
continue block;
|
||||
}
|
||||
final eventId = this.eventId;
|
||||
|
||||
final isCheckbox = node.className == 'task-list-item';
|
||||
final checkboxIndex = isCheckbox
|
||||
? node.rootElement
|
||||
.getElementsByClassName('task-list-item')
|
||||
.indexOf(node) +
|
||||
1
|
||||
: null;
|
||||
final checkedByReaction = !isCheckbox
|
||||
? null
|
||||
: checkboxCheckedEvents?.firstWhereOrNull(
|
||||
(event) => event.checkedCheckboxId == checkboxIndex,
|
||||
);
|
||||
final staticallyChecked =
|
||||
isCheckbox && node.children.first.attributes['checked'] == 'true';
|
||||
|
||||
return WidgetSpan(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: fontSize),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
if (!isCheckbox) ...[
|
||||
if (node.parent?.localName == 'ul')
|
||||
const TextSpan(text: '• '),
|
||||
if (node.parent?.localName == 'ol')
|
||||
TextSpan(
|
||||
text:
|
||||
'${(node.parent?.nodes.whereType<dom.Element>().toList().indexOf(node) ?? 0) + (int.tryParse(node.parent?.attributes['start'] ?? '1') ?? 1)}. ',
|
||||
),
|
||||
],
|
||||
if (node.className == 'task-list-item')
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: SizedBox.square(
|
||||
dimension: fontSize + 2,
|
||||
child: CupertinoCheckbox(
|
||||
checkColor: textColor,
|
||||
side: BorderSide(color: textColor),
|
||||
activeColor: textColor.withAlpha(64),
|
||||
value:
|
||||
staticallyChecked || checkedByReaction != null,
|
||||
onChanged:
|
||||
eventId == null ||
|
||||
checkboxIndex == null ||
|
||||
staticallyChecked ||
|
||||
!room.canSendDefaultMessages ||
|
||||
(checkedByReaction != null &&
|
||||
checkedByReaction.senderId !=
|
||||
room.client.userID)
|
||||
? null
|
||||
: (_) => showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => checkedByReaction != null
|
||||
? room.redactEvent(
|
||||
checkedByReaction.eventId,
|
||||
)
|
||||
: room.checkCheckbox(
|
||||
eventId,
|
||||
checkboxIndex,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
..._renderWithLineBreaks(node.nodes, context, depth: depth),
|
||||
],
|
||||
style: TextStyle(fontSize: fontSize, color: textColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'blockquote':
|
||||
return WidgetSpan(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(left: BorderSide(color: textColor, width: 5)),
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _renderWithLineBreaks(
|
||||
node.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'code':
|
||||
final isInline = node.parent?.localName != 'pre';
|
||||
final lang =
|
||||
node.className
|
||||
.split(' ')
|
||||
.singleWhereOrNull(
|
||||
(className) => className.startsWith('language-'),
|
||||
)
|
||||
?.split('language-')
|
||||
.last ??
|
||||
'md';
|
||||
final highlightedHtml = highlight
|
||||
.parse(node.text, language: lang)
|
||||
.toHtml();
|
||||
final element = parser.parse(highlightedHtml).body;
|
||||
if (element == null) {
|
||||
return const TextSpan(text: 'Unable to render code block!');
|
||||
}
|
||||
|
||||
return WidgetSpan(
|
||||
child: Material(
|
||||
color: atomOneBackgroundColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: hightlightTextColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Padding(
|
||||
padding: isInline
|
||||
? const EdgeInsets.symmetric(horizontal: 4.0)
|
||||
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
child: Text.rich(
|
||||
TextSpan(children: [_renderCodeBlockNode(element)]),
|
||||
selectionColor: hightlightTextColor.withAlpha(128),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'img':
|
||||
final mxcUrl = Uri.tryParse(node.attributes['src'] ?? '');
|
||||
if (mxcUrl == null || mxcUrl.scheme != 'mxc') {
|
||||
return TextSpan(text: node.attributes['alt']);
|
||||
}
|
||||
|
||||
final width = double.tryParse(node.attributes['width'] ?? '');
|
||||
final height = double.tryParse(node.attributes['height'] ?? '');
|
||||
const defaultDimension = 64.0;
|
||||
final actualWidth = width ?? height ?? defaultDimension;
|
||||
final actualHeight = height ?? width ?? defaultDimension;
|
||||
|
||||
return WidgetSpan(
|
||||
child: SizedBox(
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
child: MxcImage(
|
||||
uri: mxcUrl,
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
isThumbnail: (actualWidth * actualHeight) > (256 * 256),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'table':
|
||||
return WidgetSpan(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
border: TableBorder.all(color: textColor.withAlpha(100)),
|
||||
children: node.nodes
|
||||
.whereType<dom.Element>()
|
||||
.expand(
|
||||
(e) =>
|
||||
e.localName == 'thead' ||
|
||||
e.localName == 'tbody' ||
|
||||
e.localName == 'tfoot'
|
||||
? e.nodes.whereType<dom.Element>()
|
||||
: [e],
|
||||
)
|
||||
.where((e) => e.localName == 'tr')
|
||||
.map(
|
||||
(tr) => TableRow(
|
||||
children: tr.nodes
|
||||
.whereType<dom.Element>()
|
||||
.where(
|
||||
(e) => e.localName == 'td' || e.localName == 'th',
|
||||
)
|
||||
.map(
|
||||
(cell) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 3,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _renderWithLineBreaks(
|
||||
cell.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
style: cell.localName == 'th'
|
||||
? const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'thead':
|
||||
case 'tbody':
|
||||
case 'tfoot':
|
||||
case 'tr':
|
||||
case 'th':
|
||||
case 'td':
|
||||
case 'caption':
|
||||
// These are handled by the 'table' case above; if encountered
|
||||
// outside a table, render children inline as fallback.
|
||||
return TextSpan(
|
||||
children: _renderWithLineBreaks(node.nodes, context, depth: depth),
|
||||
);
|
||||
case 'hr':
|
||||
return const WidgetSpan(child: Divider());
|
||||
case 'details':
|
||||
var obscure = true;
|
||||
return WidgetSpan(
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) => InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: () => setState(() {
|
||||
obscure = !obscure;
|
||||
}),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: Icon(
|
||||
obscure ? Icons.arrow_right : Icons.arrow_drop_down,
|
||||
size: fontSize * 1.2,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
if (obscure)
|
||||
...node.nodes
|
||||
.where(
|
||||
(node) =>
|
||||
node is dom.Element &&
|
||||
node.localName == 'summary',
|
||||
)
|
||||
.map(
|
||||
(node) => _renderHtml(node, context, depth: depth),
|
||||
)
|
||||
else
|
||||
..._renderWithLineBreaks(
|
||||
node.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: TextStyle(fontSize: fontSize, color: textColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'span':
|
||||
if (!node.attributes.containsKey('data-mx-spoiler')) {
|
||||
continue block;
|
||||
}
|
||||
var obscure = true;
|
||||
return WidgetSpan(
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) => InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: () => setState(() {
|
||||
obscure = !obscure;
|
||||
}),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _renderWithLineBreaks(
|
||||
node.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
backgroundColor: obscure ? textColor : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
block:
|
||||
default:
|
||||
return TextSpan(
|
||||
style: switch (node.localName) {
|
||||
'body' => TextStyle(fontSize: fontSize, color: textColor),
|
||||
'a' => linkStyle,
|
||||
'strong' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'em' || 'i' => const TextStyle(fontStyle: FontStyle.italic),
|
||||
'del' || 's' || 'strikethrough' => const TextStyle(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
'u' => const TextStyle(decoration: TextDecoration.underline),
|
||||
'h1' => TextStyle(fontSize: fontSize * 1.6, height: 2),
|
||||
'h2' => TextStyle(fontSize: fontSize * 1.5, height: 2),
|
||||
'h3' => TextStyle(fontSize: fontSize * 1.4, height: 2),
|
||||
'h4' => TextStyle(fontSize: fontSize * 1.3, height: 1.75),
|
||||
'h5' => TextStyle(fontSize: fontSize * 1.2, height: 1.75),
|
||||
'h6' => TextStyle(fontSize: fontSize * 1.1, height: 1.5),
|
||||
'span' => TextStyle(
|
||||
color:
|
||||
node.attributes['color']?.hexToColor ??
|
||||
node.attributes['data-mx-color']?.hexToColor ??
|
||||
textColor,
|
||||
backgroundColor: node.attributes['data-mx-bg-color']?.hexToColor,
|
||||
),
|
||||
'sup' => const TextStyle(
|
||||
fontFeatures: [FontFeature.superscripts()],
|
||||
),
|
||||
'sub' => const TextStyle(fontFeatures: [FontFeature.subscripts()]),
|
||||
_ => null,
|
||||
},
|
||||
children: _renderWithLineBreaks(node.nodes, context, depth: depth),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final element = parser.parse(html).body ?? dom.Element.html('');
|
||||
final configuredMaxLines = AppSettings.messagePreviewMaxLines.value;
|
||||
final maxLines = configuredMaxLines <= 0 ? null : configuredMaxLines;
|
||||
final span = _renderHtml(element, context);
|
||||
final style = TextStyle(fontSize: fontSize, color: textColor);
|
||||
|
||||
if (maxLines == null) {
|
||||
return Text.rich(
|
||||
span,
|
||||
style: style,
|
||||
selectionColor: textColor.withAlpha(128),
|
||||
);
|
||||
}
|
||||
|
||||
// Heuristic: if plain text has enough newlines or length, it will overflow.
|
||||
final plainText = element.text;
|
||||
final lineCount = '\n'.allMatches(plainText).length + 1;
|
||||
final likelyOverflows =
|
||||
lineCount > maxLines || plainText.length > maxLines * 50;
|
||||
|
||||
if (!likelyOverflows) {
|
||||
return Text.rich(
|
||||
span,
|
||||
style: style,
|
||||
selectionColor: textColor.withAlpha(128),
|
||||
);
|
||||
}
|
||||
|
||||
return _CollapsibleText(
|
||||
span: span,
|
||||
style: style,
|
||||
maxLines: maxLines,
|
||||
textColor: textColor,
|
||||
linkColor: linkStyle.color ?? textColor,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CollapsibleText extends StatefulWidget {
|
||||
final InlineSpan span;
|
||||
final TextStyle style;
|
||||
final int maxLines;
|
||||
final Color textColor;
|
||||
final Color linkColor;
|
||||
final double fontSize;
|
||||
|
||||
const _CollapsibleText({
|
||||
required this.span,
|
||||
required this.style,
|
||||
required this.maxLines,
|
||||
required this.textColor,
|
||||
required this.linkColor,
|
||||
required this.fontSize,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CollapsibleText> createState() => _CollapsibleTextState();
|
||||
}
|
||||
|
||||
class _CollapsibleTextState extends State<_CollapsibleText> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text.rich(
|
||||
widget.span,
|
||||
style: widget.style,
|
||||
maxLines: _expanded ? null : widget.maxLines,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
selectionColor: widget.textColor.withAlpha(128),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _expanded = !_expanded),
|
||||
style: TextButton.styleFrom(foregroundColor: widget.linkColor),
|
||||
icon: Icon(_expanded ? Icons.arrow_drop_up : Icons.arrow_drop_down),
|
||||
label: Text(_expanded ? l10n.showLess : l10n.showMore),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MatrixPill extends StatelessWidget {
|
||||
final String name;
|
||||
final BuildContext outerContext;
|
||||
final Uri? avatar;
|
||||
final String uri;
|
||||
final double? fontSize;
|
||||
final Color? color;
|
||||
|
||||
const MatrixPill({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.outerContext,
|
||||
this.avatar,
|
||||
required this.uri,
|
||||
required this.fontSize,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: UrlLauncher(outerContext, uri).launchUrl,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: Avatar(mxContent: avatar, name: name, size: 16),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: name,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
decorationColor: color,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: fontSize,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
Color? get hexToColor {
|
||||
var hexCode = this;
|
||||
if (hexCode.startsWith('#')) hexCode = hexCode.substring(1);
|
||||
if (hexCode.length == 6) hexCode = 'FF$hexCode';
|
||||
final colorValue = int.tryParse(hexCode, radix: 16);
|
||||
return colorValue == null ? null : Color(colorValue);
|
||||
}
|
||||
}
|
||||
|
||||
extension on dom.Element {
|
||||
dom.Element get rootElement => parent?.rootElement ?? this;
|
||||
}
|
||||
175
lib/pages/chat/events/image_bubble.dart
Normal file
175
lib/pages/chat/events/image_bubble.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/utils/file_description.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../widgets/blur_hash.dart';
|
||||
|
||||
class ImageBubble extends StatelessWidget {
|
||||
final Event event;
|
||||
final bool tapToView;
|
||||
final BoxFit fit;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final Color? linkColor;
|
||||
final bool thumbnailOnly;
|
||||
final bool animated;
|
||||
final double width;
|
||||
final double height;
|
||||
final void Function()? onTap;
|
||||
final BorderRadius? borderRadius;
|
||||
final Timeline? timeline;
|
||||
|
||||
const ImageBubble(
|
||||
this.event, {
|
||||
this.tapToView = true,
|
||||
this.backgroundColor,
|
||||
this.fit = BoxFit.contain,
|
||||
this.thumbnailOnly = true,
|
||||
this.width = 400,
|
||||
this.height = 300,
|
||||
this.animated = false,
|
||||
this.onTap,
|
||||
this.borderRadius,
|
||||
this.timeline,
|
||||
this.textColor,
|
||||
this.linkColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
var borderRadius =
|
||||
this.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius);
|
||||
|
||||
final fileDescription = event.fileDescription;
|
||||
final textColor = this.textColor;
|
||||
|
||||
if (fileDescription != null) {
|
||||
borderRadius = borderRadius.copyWith(
|
||||
bottomLeft: Radius.zero,
|
||||
bottomRight: Radius.zero,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: borderRadius,
|
||||
side: BorderSide(
|
||||
color: event.messageType == MessageTypes.Sticker
|
||||
? Colors.transparent
|
||||
: theme.dividerColor,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Hero(
|
||||
tag: event.eventId,
|
||||
child: AppSettings.showThumbnailsInTimeline.value
|
||||
? MxcImage(
|
||||
cacheKey: event.transactionId ?? event.eventId,
|
||||
cacheName: event.room.id,
|
||||
event: event,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
animated: animated,
|
||||
isThumbnail: thumbnailOnly,
|
||||
placeholder: event.messageType == MessageTypes.Sticker
|
||||
? null
|
||||
: (_) => _ImageBubblePlaceholder(
|
||||
event: event,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
),
|
||||
)
|
||||
: _ImageBubblePlaceholder(
|
||||
event: event,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fileDescription != null && textColor != null)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Linkify(
|
||||
text: fileDescription,
|
||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageBubblePlaceholder extends StatelessWidget {
|
||||
final Event event;
|
||||
final double width, height;
|
||||
final BoxFit fit;
|
||||
|
||||
const _ImageBubblePlaceholder({
|
||||
required this.event,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.fit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blurHashString =
|
||||
event.infoMap.tryGet<String>('xyz.amorgan.blurhash') ??
|
||||
'LEHV6nWB2yk8pyo0adR*.7kCMdnj';
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: BlurHash(
|
||||
blurhash: blurHashString,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/pages/chat/events/map_bubble.dart
Normal file
105
lib/pages/chat/events/map_bubble.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class MapBubble extends StatelessWidget {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double zoom;
|
||||
final double width;
|
||||
final double height;
|
||||
final double radius;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MapBubble({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.zoom = 14.0,
|
||||
this.width = 400,
|
||||
this.height = 400,
|
||||
this.radius = 10.0,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints.loose(Size(width, height)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: width / height,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: LatLng(latitude, longitude),
|
||||
initialZoom: zoom,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
maxZoom: 20,
|
||||
minZoom: 0,
|
||||
urlTemplate:
|
||||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: AppConfig.appId,
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
),
|
||||
MarkerLayer(
|
||||
rotate: true,
|
||||
markers: [
|
||||
Marker(
|
||||
point: LatLng(latitude, longitude),
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Transform.translate(
|
||||
// No idea why the offset has to be like this, instead of -15
|
||||
// It has been determined by trying out, though, that this yields
|
||||
// the tip of the location pin to be static when zooming.
|
||||
// Might have to do with psychological perception of where the tip exactly is
|
||||
offset: const Offset(0, -12.5),
|
||||
child: const Icon(
|
||||
Icons.location_pin,
|
||||
color: Colors.red,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Text(
|
||||
' © OpenStreetMap contributors ',
|
||||
style: TextStyle(
|
||||
color: theme.brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
backgroundColor: theme.appBarTheme.backgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: Tooltip(
|
||||
message: L10n.of(context).openInMaps,
|
||||
child: InkWell(onTap: onTap, child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1037
lib/pages/chat/events/message.dart
Normal file
1037
lib/pages/chat/events/message.dart
Normal file
File diff suppressed because it is too large
Load Diff
321
lib/pages/chat/events/message_content.dart
Normal file
321
lib/pages/chat/events/message_content.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/events/poll.dart';
|
||||
import 'package:tatchat/pages/chat/events/video_player.dart';
|
||||
import 'package:tatchat/pages/image_viewer/image_viewer.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../utils/event_checkbox_extension.dart';
|
||||
import '../../../utils/platform_infos.dart';
|
||||
import '../../../utils/url_launcher.dart';
|
||||
import 'audio_player.dart';
|
||||
import 'cute_events.dart';
|
||||
import 'html_message.dart';
|
||||
import 'image_bubble.dart';
|
||||
import 'map_bubble.dart';
|
||||
import 'message_download_content.dart';
|
||||
|
||||
class MessageContent extends StatelessWidget {
|
||||
final Event event;
|
||||
final Color textColor;
|
||||
final Color linkColor;
|
||||
final void Function(Event)? onInfoTab;
|
||||
final BorderRadius borderRadius;
|
||||
final Timeline timeline;
|
||||
final bool selected;
|
||||
final Set<String> bigEmojis;
|
||||
|
||||
const MessageContent(
|
||||
this.event, {
|
||||
this.onInfoTab,
|
||||
super.key,
|
||||
required this.timeline,
|
||||
required this.textColor,
|
||||
required this.linkColor,
|
||||
required this.borderRadius,
|
||||
required this.selected,
|
||||
required this.bigEmojis,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const fontSize = AppConfig.messageFontSize;
|
||||
final buttonTextColor = textColor;
|
||||
switch (event.type) {
|
||||
case EventTypes.Message:
|
||||
case EventTypes.Encrypted:
|
||||
case EventTypes.Sticker:
|
||||
switch (event.messageType) {
|
||||
case MessageTypes.Image:
|
||||
case MessageTypes.Sticker:
|
||||
if (event.redacted) continue textmessage;
|
||||
final maxSize = event.messageType == MessageTypes.Sticker
|
||||
? 128.0
|
||||
: 256.0;
|
||||
final w = event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<int>('w');
|
||||
final h = event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<int>('h');
|
||||
var width = maxSize;
|
||||
var height = maxSize;
|
||||
var fit = event.messageType == MessageTypes.Sticker
|
||||
? BoxFit.contain
|
||||
: BoxFit.cover;
|
||||
if (w != null && h != null) {
|
||||
fit = BoxFit.contain;
|
||||
if (w > h) {
|
||||
width = maxSize;
|
||||
height = max(32, maxSize * (h / w));
|
||||
} else {
|
||||
height = maxSize;
|
||||
width = max(32, maxSize * (w / h));
|
||||
}
|
||||
}
|
||||
return ImageBubble(
|
||||
event,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
borderRadius: borderRadius,
|
||||
timeline: timeline,
|
||||
textColor: textColor,
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewer(
|
||||
event,
|
||||
timeline: timeline,
|
||||
outerContext: context,
|
||||
),
|
||||
),
|
||||
);
|
||||
case CuteEventContent.eventType:
|
||||
return CuteContent(event);
|
||||
case MessageTypes.Audio:
|
||||
if (PlatformInfos.isMobile ||
|
||||
PlatformInfos.isMacOS ||
|
||||
PlatformInfos.isWeb ||
|
||||
PlatformInfos.isLinux) {
|
||||
return AudioPlayerWidget(
|
||||
event,
|
||||
color: textColor,
|
||||
linkColor: linkColor,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
return MessageDownloadContent(
|
||||
event,
|
||||
textColor: textColor,
|
||||
linkColor: linkColor,
|
||||
);
|
||||
case MessageTypes.Video:
|
||||
return EventVideoPlayer(
|
||||
event,
|
||||
textColor: textColor,
|
||||
linkColor: linkColor,
|
||||
timeline: timeline,
|
||||
);
|
||||
case MessageTypes.File:
|
||||
return MessageDownloadContent(
|
||||
event,
|
||||
textColor: textColor,
|
||||
linkColor: linkColor,
|
||||
);
|
||||
case MessageTypes.Location:
|
||||
final geoUri = Uri.tryParse(
|
||||
event.content.tryGet<String>('geo_uri')!,
|
||||
);
|
||||
if (geoUri != null && geoUri.scheme == 'geo') {
|
||||
final latlong = geoUri.path
|
||||
.split(';')
|
||||
.first
|
||||
.split(',')
|
||||
.map(double.tryParse)
|
||||
.toList();
|
||||
if (latlong.length == 2 &&
|
||||
latlong.first != null &&
|
||||
latlong.last != null) {
|
||||
return MapBubble(
|
||||
onTap: () =>
|
||||
UrlLauncher(context, geoUri.toString()).launchUrl(),
|
||||
latitude: latlong.first!,
|
||||
longitude: latlong.last!,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue textmessage;
|
||||
case MessageTypes.Text:
|
||||
case MessageTypes.Notice:
|
||||
case MessageTypes.Emote:
|
||||
case MessageTypes.None:
|
||||
textmessage:
|
||||
default:
|
||||
if (event.redacted) {
|
||||
return RedactionWidget(
|
||||
event: event,
|
||||
buttonTextColor: buttonTextColor,
|
||||
onInfoTab: onInfoTab,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
var html = AppSettings.renderHtml.value && event.isRichMessage
|
||||
? event.formattedText
|
||||
: event.body.replaceAll('<', '<').replaceAll('>', '>');
|
||||
if (event.messageType == MessageTypes.Emote) {
|
||||
html = '* $html';
|
||||
}
|
||||
|
||||
final bigEmotes =
|
||||
!event.isRichMessage && bigEmojis.contains(event.body);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: HtmlMessage(
|
||||
html: html,
|
||||
textColor: textColor,
|
||||
room: event.room,
|
||||
fontSize: AppConfig.messageFontSize * (bigEmotes ? 5 : 1),
|
||||
linkStyle: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
eventId: event.eventId,
|
||||
checkboxCheckedEvents: event.aggregatedEvents(
|
||||
timeline,
|
||||
EventCheckboxRoomExtension.relationshipType,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
case PollEventContent.startType:
|
||||
if (event.redacted) {
|
||||
return RedactionWidget(
|
||||
event: event,
|
||||
buttonTextColor: buttonTextColor,
|
||||
onInfoTab: onInfoTab,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
return PollWidget(
|
||||
event: event,
|
||||
timeline: timeline,
|
||||
textColor: textColor,
|
||||
linkColor: linkColor,
|
||||
);
|
||||
case EventTypes.CallInvite:
|
||||
return FutureBuilder<User?>(
|
||||
future: event.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
return _ButtonContent(
|
||||
label: L10n.of(context).startedACall(
|
||||
snapshot.data?.calcDisplayname() ??
|
||||
event.senderFromMemoryOrFallback.calcDisplayname(),
|
||||
),
|
||||
icon: '📞',
|
||||
textColor: buttonTextColor,
|
||||
onPressed: () => onInfoTab!(event),
|
||||
fontSize: fontSize,
|
||||
);
|
||||
},
|
||||
);
|
||||
default:
|
||||
return FutureBuilder<User?>(
|
||||
future: event.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
return _ButtonContent(
|
||||
label: L10n.of(context).userSentUnknownEvent(
|
||||
snapshot.data?.calcDisplayname() ??
|
||||
event.senderFromMemoryOrFallback.calcDisplayname(),
|
||||
event.type,
|
||||
),
|
||||
icon: 'ℹ️',
|
||||
textColor: buttonTextColor,
|
||||
onPressed: () => onInfoTab!(event),
|
||||
fontSize: fontSize,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RedactionWidget extends StatelessWidget {
|
||||
const RedactionWidget({
|
||||
super.key,
|
||||
required this.event,
|
||||
required this.buttonTextColor,
|
||||
required this.onInfoTab,
|
||||
required this.fontSize,
|
||||
});
|
||||
|
||||
final Event event;
|
||||
final Color buttonTextColor;
|
||||
final void Function(Event p1)? onInfoTab;
|
||||
final double fontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<User?>(
|
||||
future: event.redactedBecause?.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
final reason = event.redactedBecause?.content.tryGet<String>('reason');
|
||||
final redactedBy =
|
||||
snapshot.data?.calcDisplayname() ??
|
||||
event.redactedBecause?.senderId.localpart ??
|
||||
L10n.of(context).user;
|
||||
return _ButtonContent(
|
||||
label: reason == null
|
||||
? L10n.of(context).redactedBy(redactedBy)
|
||||
: L10n.of(context).redactedByBecause(redactedBy, reason),
|
||||
icon: '🗑️',
|
||||
textColor: buttonTextColor.withAlpha(128),
|
||||
onPressed: () => onInfoTab!(event),
|
||||
fontSize: fontSize,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ButtonContent extends StatelessWidget {
|
||||
final void Function() onPressed;
|
||||
final String label;
|
||||
final String icon;
|
||||
final Color? textColor;
|
||||
final double fontSize;
|
||||
|
||||
const _ButtonContent({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.textColor,
|
||||
required this.onPressed,
|
||||
required this.fontSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
child: Text(
|
||||
'$icon $label',
|
||||
style: TextStyle(color: textColor, fontSize: fontSize),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/pages/chat/events/message_download_content.dart
Normal file
124
lib/pages/chat/events/message_download_content.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/pages/chat/events/file_send_status_indicator.dart';
|
||||
import 'package:tatchat/utils/file_description.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class MessageDownloadContent extends StatelessWidget {
|
||||
final Event event;
|
||||
final Color textColor;
|
||||
final Color linkColor;
|
||||
|
||||
const MessageDownloadContent(
|
||||
this.event, {
|
||||
required this.textColor,
|
||||
required this.linkColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filename = event.content.tryGet<String>('filename') ?? event.body;
|
||||
final filetype = (filename.contains('.')
|
||||
? filename.split('.').last.toUpperCase()
|
||||
: event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<String>('mimetype')
|
||||
?.toUpperCase() ??
|
||||
'UNKNOWN');
|
||||
final sizeString = event.sizeString ?? '?MB';
|
||||
final fileDescription = event.fileDescription;
|
||||
final fileSendingStatus = event.fileSendingStatus;
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
onTap: () => event.saveFile(context),
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 16,
|
||||
children: [
|
||||
if (fileSendingStatus != null)
|
||||
FileSendStatusIndicator(
|
||||
fileSendingStatus: fileSendingStatus,
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
backgroundColor: textColor.withAlpha(32),
|
||||
child: Icon(
|
||||
Icons.file_download_outlined,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(
|
||||
filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$sizeString | $filetype',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: textColor, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fileDescription != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Linkify(
|
||||
text: fileDescription,
|
||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
208
lib/pages/chat/events/message_reactions.dart
Normal file
208
lib/pages/chat/events/message_reactions.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class MessageReactions extends StatelessWidget {
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
|
||||
const MessageReactions(this.event, this.timeline, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allReactionEvents = event.aggregatedEvents(
|
||||
timeline,
|
||||
RelationshipTypes.reaction,
|
||||
);
|
||||
final reactionMap = <String, _ReactionEntry>{};
|
||||
final client = Matrix.of(context).client;
|
||||
|
||||
for (final e in allReactionEvents) {
|
||||
final key = e.content
|
||||
.tryGetMap<String, Object?>('m.relates_to')
|
||||
?.tryGet<String>('key');
|
||||
if (key != null) {
|
||||
if (!reactionMap.containsKey(key)) {
|
||||
reactionMap[key] = _ReactionEntry(
|
||||
key: key,
|
||||
count: 0,
|
||||
reacted: false,
|
||||
reactors: [],
|
||||
);
|
||||
}
|
||||
reactionMap[key]!.count++;
|
||||
reactionMap[key]!.reactors!.add(e.senderFromMemoryOrFallback);
|
||||
reactionMap[key]!.reacted |= e.senderId == e.room.client.userID;
|
||||
}
|
||||
}
|
||||
|
||||
final reactionList = reactionMap.values.toList();
|
||||
reactionList.sort((a, b) => b.count - a.count > 0 ? 1 : -1);
|
||||
final ownMessage = event.senderId == event.room.client.userID;
|
||||
return Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 4.0,
|
||||
alignment: ownMessage ? WrapAlignment.end : WrapAlignment.start,
|
||||
children: [
|
||||
...reactionList.map(
|
||||
(r) => _Reaction(
|
||||
reactionKey: r.key,
|
||||
count: r.count,
|
||||
reacted: r.reacted,
|
||||
onTap: () {
|
||||
if (r.reacted) {
|
||||
final evt = allReactionEvents.firstWhereOrNull(
|
||||
(e) =>
|
||||
e.senderId == e.room.client.userID &&
|
||||
e.content.tryGetMap('m.relates_to')?['key'] == r.key,
|
||||
);
|
||||
if (evt != null) {
|
||||
showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: evt.redactEvent,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
event.room.sendReaction(event.eventId, r.key);
|
||||
}
|
||||
},
|
||||
onLongPress: () async => await _AdaptableReactorsDialog(
|
||||
client: client,
|
||||
reactionEntry: r,
|
||||
).show(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Reaction extends StatelessWidget {
|
||||
final String reactionKey;
|
||||
final int count;
|
||||
final bool? reacted;
|
||||
final void Function()? onTap;
|
||||
final void Function()? onLongPress;
|
||||
|
||||
const _Reaction({
|
||||
required this.reactionKey,
|
||||
required this.count,
|
||||
required this.reacted,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
Widget content;
|
||||
if (reactionKey.startsWith('mxc://')) {
|
||||
content = MxcImage(
|
||||
uri: Uri.parse(reactionKey),
|
||||
width: 20,
|
||||
height: 20,
|
||||
animated: false,
|
||||
isThumbnail: false,
|
||||
);
|
||||
} else {
|
||||
var renderKey = Characters(reactionKey);
|
||||
if (renderKey.length > 10) {
|
||||
renderKey = renderKey.getRange(0, 9) + Characters('…');
|
||||
}
|
||||
content = Text(renderKey.toString(), style: TextStyle(fontSize: 14));
|
||||
}
|
||||
return Badge(
|
||||
isLabelVisible: count > 1,
|
||||
label: Text(count.toString()),
|
||||
textStyle: TextStyle(fontSize: 10),
|
||||
textColor: theme.colorScheme.onPrimary,
|
||||
backgroundColor: theme.colorScheme.primary.withAlpha(200),
|
||||
child: InkWell(
|
||||
onTap: () => onTap != null ? onTap!() : null,
|
||||
onLongPress: () => onLongPress != null ? onLongPress!() : null,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
border: Border.all(
|
||||
color: reacted == true
|
||||
? theme.colorScheme.secondary
|
||||
: theme.colorScheme.secondaryContainer,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
),
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionEntry {
|
||||
String key;
|
||||
int count;
|
||||
bool reacted;
|
||||
List<User>? reactors;
|
||||
|
||||
_ReactionEntry({
|
||||
required this.key,
|
||||
required this.count,
|
||||
required this.reacted,
|
||||
this.reactors,
|
||||
});
|
||||
}
|
||||
|
||||
class _AdaptableReactorsDialog extends StatelessWidget {
|
||||
final Client? client;
|
||||
final _ReactionEntry? reactionEntry;
|
||||
|
||||
const _AdaptableReactorsDialog({this.client, this.reactionEntry});
|
||||
|
||||
Future<bool?> show(BuildContext context) => showDialog(
|
||||
context: context,
|
||||
builder: (context) => this,
|
||||
barrierDismissible: true,
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
alignment: WrapAlignment.center,
|
||||
children: <Widget>[
|
||||
for (final reactor in reactionEntry!.reactors!)
|
||||
Chip(
|
||||
avatar: Avatar(
|
||||
mxContent: reactor.avatarUrl,
|
||||
name: reactor.displayName,
|
||||
client: client,
|
||||
presenceUserId: reactor.stateKey,
|
||||
),
|
||||
label: Text(reactor.displayName!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final title = Center(child: Text(reactionEntry!.key));
|
||||
|
||||
return AlertDialog(title: title, content: body);
|
||||
}
|
||||
}
|
||||
224
lib/pages/chat/events/poll.dart
Normal file
224
lib/pages/chat/events/poll.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart' hide Result;
|
||||
|
||||
class PollWidget extends StatelessWidget {
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
final Color textColor;
|
||||
final Color linkColor;
|
||||
const PollWidget({
|
||||
required this.event,
|
||||
required this.timeline,
|
||||
required this.textColor,
|
||||
required this.linkColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
void _endPoll(BuildContext context) =>
|
||||
showFutureLoadingDialog(context: context, future: event.endPoll);
|
||||
|
||||
void _toggleVote(BuildContext context, String answerId, int maxSelection) {
|
||||
final userId = event.room.client.userID!;
|
||||
final answerIds = event.getPollResponses(timeline)[userId] ?? {};
|
||||
if (!answerIds.remove(answerId)) {
|
||||
answerIds.add(answerId);
|
||||
if (answerIds.length > maxSelection) {
|
||||
answerIds.clear();
|
||||
answerIds.add(answerId);
|
||||
}
|
||||
}
|
||||
|
||||
showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => event.answerPoll(answerIds.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final eventContentResult = Result(() => event.parsedPollEventContent);
|
||||
final eventContent = eventContentResult.asValue?.value;
|
||||
if (eventContent == null) {
|
||||
Logs().w('Invalid poll event', eventContentResult.error);
|
||||
return const Text('Unable to parse poll event...');
|
||||
}
|
||||
final responses = event.getPollResponses(timeline);
|
||||
final pollHasBeenEnded = event.getPollHasBeenEnded(timeline);
|
||||
final canVote =
|
||||
event.room.canSendEvent(PollEventContent.responseType) &&
|
||||
!pollHasBeenEnded;
|
||||
final maxPolls = responses.length;
|
||||
final answersVisible =
|
||||
eventContent.pollStartContent.kind == PollKind.disclosed ||
|
||||
pollHasBeenEnded;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Linkify(
|
||||
text: eventContent.pollStartContent.question.mText,
|
||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
Divider(color: linkColor.withAlpha(64)),
|
||||
...eventContent.pollStartContent.answers.map((answer) {
|
||||
final votedUserIds = responses.entries
|
||||
.where((entry) => entry.value.contains(answer.id))
|
||||
.map((entry) => entry.key)
|
||||
.toSet();
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: CheckboxListTile.adaptive(
|
||||
value:
|
||||
responses[event.room.client.userID!]?.contains(answer.id) ??
|
||||
false,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
checkboxScaleFactor: 1.5,
|
||||
checkboxShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
onChanged: !canVote
|
||||
? null
|
||||
: (_) => _toggleVote(
|
||||
context,
|
||||
answer.id,
|
||||
eventContent.pollStartContent.maxSelections,
|
||||
),
|
||||
title: Text(
|
||||
answer.mText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
),
|
||||
),
|
||||
subtitle: answersVisible
|
||||
? Column(
|
||||
crossAxisAlignment: .start,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(
|
||||
context,
|
||||
).countVotes(votedUserIds.length),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
...votedUserIds.map((userId) {
|
||||
final user = event.room
|
||||
.getState(EventTypes.RoomMember, userId)
|
||||
?.asUser(event.room);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 2.0,
|
||||
),
|
||||
child: Avatar(
|
||||
mxContent: user?.avatarUrl,
|
||||
name:
|
||||
user?.calcDisplayname() ??
|
||||
userId.localpart,
|
||||
size: 12,
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(width: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
LinearProgressIndicator(
|
||||
color: linkColor,
|
||||
backgroundColor: linkColor.withAlpha(128),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
value: maxPolls == 0
|
||||
? 0
|
||||
: votedUserIds.length / maxPolls,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (!pollHasBeenEnded && event.senderId == event.room.client.userID)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _endPoll(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: linkColor,
|
||||
side: BorderSide(color: linkColor.withAlpha(64)),
|
||||
),
|
||||
child: Text(L10n.of(context).endPoll),
|
||||
),
|
||||
)
|
||||
else if (!answersVisible)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
L10n.of(context).answersWillBeVisibleWhenPollHasEnded,
|
||||
style: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (pollHasBeenEnded)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
L10n.of(context).pollHasBeenEnded,
|
||||
style: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
lib/pages/chat/events/reply_content.dart
Normal file
110
lib/pages/chat/events/reply_content.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class ReplyContent extends StatelessWidget {
|
||||
final Event replyEvent;
|
||||
final bool ownMessage;
|
||||
final Timeline? timeline;
|
||||
|
||||
const ReplyContent(
|
||||
this.replyEvent, {
|
||||
this.ownMessage = false,
|
||||
super.key,
|
||||
this.timeline,
|
||||
});
|
||||
|
||||
static const BorderRadius borderRadius = BorderRadius.only(
|
||||
topRight: Radius.circular(AppConfig.borderRadius / 2),
|
||||
bottomRight: Radius.circular(AppConfig.borderRadius / 2),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final timeline = this.timeline;
|
||||
final displayEvent = timeline != null
|
||||
? replyEvent.getDisplayEvent(timeline)
|
||||
: replyEvent;
|
||||
const fontSize = AppConfig.messageFontSize;
|
||||
final color = theme.brightness == Brightness.dark
|
||||
? theme.colorScheme.onTertiaryContainer
|
||||
: ownMessage
|
||||
? theme.colorScheme.tertiaryContainer
|
||||
: theme.colorScheme.tertiary;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: borderRadius,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 5,
|
||||
height: fontSize * 2 + 16,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
mainAxisAlignment: .center,
|
||||
children: <Widget>[
|
||||
FutureBuilder<User?>(
|
||||
initialData: displayEvent.senderFromMemoryOrFallback,
|
||||
future: displayEvent.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
return Text(
|
||||
'${snapshot.data?.calcDisplayname() ?? displayEvent.senderFromMemoryOrFallback.calcDisplayname()}:',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
displayEvent
|
||||
.calcLocalizedBodyFallback(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
withSenderNamePrefix: false,
|
||||
hideReply: true,
|
||||
plaintextBody: true,
|
||||
)
|
||||
.trim()
|
||||
.replaceAll('\n', ' '),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: theme.brightness == Brightness.dark
|
||||
? theme.colorScheme.onSurface
|
||||
: ownMessage
|
||||
? theme.colorScheme.onTertiary
|
||||
: theme.colorScheme.onSurface,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
lib/pages/chat/events/state_message.dart
Normal file
123
lib/pages/chat/events/state_message.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/utd_dialog.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class StateMessage extends StatelessWidget {
|
||||
final Event event;
|
||||
final void Function()? onExpand;
|
||||
final bool isCollapsed;
|
||||
const StateMessage(
|
||||
this.event, {
|
||||
this.onExpand,
|
||||
this.isCollapsed = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: isCollapsed
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface.withAlpha(128),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius / 3,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
if (event.type != EventTypes.Encrypted)
|
||||
TextSpan(
|
||||
text: event.calcLocalizedBodyFallback(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: Avatar(
|
||||
name: event.senderFromMemoryOrFallback
|
||||
.calcDisplayname(),
|
||||
size: 14,
|
||||
mxContent: event
|
||||
.senderFromMemoryOrFallback
|
||||
.avatarUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: L10n.of(context).messageNotDecryptable,
|
||||
),
|
||||
if (!event.redacted)
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
onTap: () =>
|
||||
UtdDialog.show(context, event),
|
||||
child: Icon(
|
||||
Icons.info_outlined,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (onExpand != null) ...[
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: theme.colorScheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = onExpand,
|
||||
text: L10n.of(context).moreEvents,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
decoration: event.redacted
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
163
lib/pages/chat/events/video_player.dart
Normal file
163
lib/pages/chat/events/video_player.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/utils/file_description.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:tatchat/widgets/blur_hash.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../image_viewer/image_viewer.dart';
|
||||
|
||||
class EventVideoPlayer extends StatelessWidget {
|
||||
final Event event;
|
||||
final Timeline? timeline;
|
||||
final Color? textColor;
|
||||
final Color? linkColor;
|
||||
|
||||
const EventVideoPlayer(
|
||||
this.event, {
|
||||
this.timeline,
|
||||
this.textColor,
|
||||
this.linkColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
static const String fallbackBlurHash = 'L5H2EC=PM+yV0g-mq.wG9c010J}I';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final supportsVideoPlayer = PlatformInfos.supportsVideoPlayer;
|
||||
|
||||
final blurHash =
|
||||
(event.infoMap as Map<String, dynamic>).tryGet<String>(
|
||||
'xyz.amorgan.blurhash',
|
||||
) ??
|
||||
fallbackBlurHash;
|
||||
final fileDescription = event.fileDescription;
|
||||
const maxDimension = 300.0;
|
||||
final infoMap = event.content.tryGetMap<String, Object?>('info');
|
||||
final videoWidth = infoMap?.tryGet<int>('w') ?? maxDimension;
|
||||
final videoHeight = infoMap?.tryGet<int>('h') ?? maxDimension;
|
||||
|
||||
final modifier = max(videoWidth, videoHeight) / maxDimension;
|
||||
final width = videoWidth / modifier;
|
||||
final height = videoHeight / modifier;
|
||||
|
||||
final durationInt = infoMap?.tryGet<int>('duration');
|
||||
final duration = durationInt == null
|
||||
? null
|
||||
: Duration(milliseconds: durationInt);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: InkWell(
|
||||
onTap: () => supportsVideoPlayer
|
||||
? showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewer(
|
||||
event,
|
||||
timeline: timeline,
|
||||
outerContext: context,
|
||||
),
|
||||
)
|
||||
: event.saveFile(context),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Hero(
|
||||
tag: event.eventId,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (event.hasThumbnail &&
|
||||
AppSettings.showThumbnailsInTimeline.value)
|
||||
MxcImage(
|
||||
event: event,
|
||||
cacheKey: event.transactionId ?? event.eventId,
|
||||
cacheName: event.room.id,
|
||||
isThumbnail: true,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context) => BlurHash(
|
||||
blurhash: blurHash,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
else
|
||||
BlurHash(
|
||||
blurhash: blurHash,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Center(
|
||||
child: CircleAvatar(
|
||||
child: supportsVideoPlayer
|
||||
? const Icon(Icons.play_arrow_outlined)
|
||||
: const Icon(Icons.file_download_outlined),
|
||||
),
|
||||
),
|
||||
if (duration != null)
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 16,
|
||||
child: Text(
|
||||
'${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
backgroundColor: Colors.black.withAlpha(32),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fileDescription != null && textColor != null && linkColor != null)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Linkify(
|
||||
text: fileDescription,
|
||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
37
lib/pages/chat/image_edit_geometry.dart
Normal file
37
lib/pages/chat/image_edit_geometry.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:crop_image/crop_image.dart';
|
||||
|
||||
/// Pure coordinate/rotation helpers for the image editor in
|
||||
/// `send_file_dialog.dart`. Kept dependency-free (only `dart:ui` and
|
||||
/// `crop_image`) so they can be unit-tested without spinning up the app.
|
||||
|
||||
/// Maps a quarter-turn count (positive = clockwise) to the [CropRotation] used
|
||||
/// when baking the final image, so the on-screen `RotatedBox` preview and the
|
||||
/// saved result always rotate the same way.
|
||||
CropRotation imageEditorRotationForQuarterTurns(int quarterTurns) {
|
||||
switch (((quarterTurns % 4) + 4) % 4) {
|
||||
case 1:
|
||||
return CropRotation.right;
|
||||
case 2:
|
||||
return CropRotation.down;
|
||||
case 3:
|
||||
return CropRotation.left;
|
||||
default:
|
||||
return CropRotation.up;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a pointer position on the draw surface (logical pixels) to a coordinate
|
||||
/// in the source image's pixel space, given the currently shown crop region
|
||||
/// [cropPx] displayed at [displaySize]. The mapping is a uniform scale plus
|
||||
/// translation because the draw surface preserves the crop's aspect ratio.
|
||||
Offset imageEditorLocalToImagePx(Offset local, Size displaySize, Rect cropPx) {
|
||||
final scale = cropPx.width / displaySize.width;
|
||||
return Offset(cropPx.left + local.dx * scale, cropPx.top + local.dy * scale);
|
||||
}
|
||||
475
lib/pages/chat/input_bar.dart
Normal file
475
lib/pages/chat/input_bar.dart
Normal file
@@ -0,0 +1,475 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/trust_user_key_dialog.dart';
|
||||
import 'package:tatchat/utils/markdown_context_builder.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:slugify/slugify.dart';
|
||||
|
||||
import '../../widgets/avatar.dart';
|
||||
import '../../widgets/matrix.dart';
|
||||
import 'command_hints.dart';
|
||||
|
||||
class InputBar extends StatelessWidget {
|
||||
final Room room;
|
||||
final int? minLines;
|
||||
final int? maxLines;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final ValueChanged<Uint8List?>? onSubmitImage;
|
||||
final FocusNode? focusNode;
|
||||
final TextEditingController? controller;
|
||||
final InputDecoration decoration;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final bool? autofocus;
|
||||
final bool readOnly;
|
||||
final List<Emoji> suggestionEmojis;
|
||||
|
||||
const InputBar({
|
||||
required this.room,
|
||||
this.minLines,
|
||||
this.maxLines,
|
||||
this.keyboardType,
|
||||
this.onSubmitted,
|
||||
this.onSubmitImage,
|
||||
this.focusNode,
|
||||
this.controller,
|
||||
required this.decoration,
|
||||
this.onChanged,
|
||||
this.autofocus,
|
||||
this.textInputAction,
|
||||
this.readOnly = false,
|
||||
required this.suggestionEmojis,
|
||||
super.key,
|
||||
});
|
||||
|
||||
List<Map<String, String?>> getSuggestions(TextEditingValue text) {
|
||||
if (text.selection.baseOffset != text.selection.extentOffset ||
|
||||
text.selection.baseOffset < 0) {
|
||||
return []; // no entries if there is selected text
|
||||
}
|
||||
final searchText = text.text.substring(0, text.selection.baseOffset);
|
||||
final ret = <Map<String, String?>>[];
|
||||
const maxResults = 30;
|
||||
|
||||
final commandMatch = RegExp(r'^/(\w*)$').firstMatch(searchText);
|
||||
if (commandMatch != null) {
|
||||
final commandSearch = commandMatch[1]!.toLowerCase();
|
||||
for (final command in room.client.commands.keys) {
|
||||
if (command.contains(commandSearch)) {
|
||||
ret.add({'type': 'command', 'name': command});
|
||||
}
|
||||
|
||||
if (ret.length > maxResults) return ret;
|
||||
}
|
||||
}
|
||||
final emojiMatch = RegExp(
|
||||
r'(?:\s|^):(?:([\p{L}\p{N}_-]+)~)?([\p{L}\p{N}_-]+)$',
|
||||
unicode: true,
|
||||
).firstMatch(searchText);
|
||||
if (emojiMatch != null) {
|
||||
final packSearch = emojiMatch[1];
|
||||
final emoteSearch = emojiMatch[2]!.toLowerCase();
|
||||
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
|
||||
if (packSearch == null || packSearch.isEmpty) {
|
||||
for (final pack in emotePacks.entries) {
|
||||
for (final emote in pack.value.images.entries) {
|
||||
if (emote.key.toLowerCase().contains(emoteSearch)) {
|
||||
ret.add({
|
||||
'type': 'emote',
|
||||
'name': emote.key,
|
||||
'pack': pack.key,
|
||||
'pack_avatar_url': pack.value.pack.avatarUrl?.toString(),
|
||||
'pack_display_name': pack.value.pack.displayName ?? pack.key,
|
||||
'mxc': emote.value.url.toString(),
|
||||
});
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (emotePacks[packSearch] != null) {
|
||||
for (final emote in emotePacks[packSearch]!.images.entries) {
|
||||
if (emote.key.toLowerCase().contains(emoteSearch)) {
|
||||
ret.add({
|
||||
'type': 'emote',
|
||||
'name': emote.key,
|
||||
'pack': packSearch,
|
||||
'pack_avatar_url': emotePacks[packSearch]!.pack.avatarUrl
|
||||
?.toString(),
|
||||
'pack_display_name':
|
||||
emotePacks[packSearch]!.pack.displayName ?? packSearch,
|
||||
'mxc': emote.value.url.toString(),
|
||||
});
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// aside of emote packs, also propose normal (tm) unicode emojis
|
||||
final matchingUnicodeEmojis = suggestionEmojis
|
||||
.where((emoji) => emoji.name.toLowerCase().contains(emoteSearch))
|
||||
.toList();
|
||||
|
||||
// sort by the index of the search term in the name in order to have
|
||||
// best matches first
|
||||
// (thanks for the hint by github.com/nextcloud/circles devs)
|
||||
matchingUnicodeEmojis.sort((a, b) {
|
||||
final indexA = a.name.indexOf(emoteSearch);
|
||||
final indexB = b.name.indexOf(emoteSearch);
|
||||
if (indexA == -1 || indexB == -1) {
|
||||
if (indexA == indexB) return 0;
|
||||
if (indexA == -1) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return indexA.compareTo(indexB);
|
||||
});
|
||||
for (final emoji in matchingUnicodeEmojis) {
|
||||
ret.add({
|
||||
'type': 'emoji',
|
||||
'emoji': emoji.emoji,
|
||||
'label': emoji.name,
|
||||
'current_word': ':$emoteSearch',
|
||||
});
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
final userMatch = RegExp(r'(?:\s|^)@([-\w]+)$').firstMatch(searchText);
|
||||
if (userMatch != null) {
|
||||
final userSearch = userMatch[1]!.toLowerCase();
|
||||
for (final user in room.getParticipants()) {
|
||||
if ((user.displayName != null &&
|
||||
(user.displayName!.toLowerCase().contains(userSearch) ||
|
||||
slugify(
|
||||
user.displayName!.toLowerCase(),
|
||||
).contains(userSearch))) ||
|
||||
user.id.localpart!.toLowerCase().contains(userSearch)) {
|
||||
ret.add({
|
||||
'type': 'user',
|
||||
'mxid': user.id,
|
||||
'mention': user.mention,
|
||||
'displayname': user.displayName,
|
||||
'avatar_url': user.avatarUrl?.toString(),
|
||||
});
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
final roomMatch = RegExp(r'(?:\s|^)#([-\w]+)$').firstMatch(searchText);
|
||||
if (roomMatch != null) {
|
||||
final roomSearch = roomMatch[1]!.toLowerCase();
|
||||
for (final r in room.client.rooms) {
|
||||
if (r.getState(EventTypes.RoomTombstone) != null) {
|
||||
continue; // we don't care about tombstoned rooms
|
||||
}
|
||||
final state = r.getState(EventTypes.RoomCanonicalAlias);
|
||||
if ((state != null &&
|
||||
((state.content['alias'] is String &&
|
||||
state.content
|
||||
.tryGet<String>('alias')!
|
||||
.localpart!
|
||||
.toLowerCase()
|
||||
.contains(roomSearch)) ||
|
||||
(state.content['alt_aliases'] is List &&
|
||||
(state.content['alt_aliases'] as List).any(
|
||||
(l) =>
|
||||
l is String &&
|
||||
l.localpart!.toLowerCase().contains(roomSearch),
|
||||
)))) ||
|
||||
(r.name.toLowerCase().contains(roomSearch))) {
|
||||
ret.add({
|
||||
'type': 'room',
|
||||
'mxid': (r.canonicalAlias.isNotEmpty) ? r.canonicalAlias : r.id,
|
||||
'displayname': r.getLocalizedDisplayname(),
|
||||
'avatar_url': r.avatar?.toString(),
|
||||
});
|
||||
}
|
||||
if (ret.length > maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
Widget buildSuggestion(
|
||||
BuildContext context,
|
||||
Map<String, String?> suggestion,
|
||||
void Function(Map<String, String?>) onSelected,
|
||||
Client? client,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
const size = 30.0;
|
||||
if (suggestion['type'] == 'command') {
|
||||
final command = suggestion['name']!;
|
||||
final hint = commandHint(L10n.of(context), command);
|
||||
return Tooltip(
|
||||
message: hint,
|
||||
waitDuration: const Duration(days: 1), // don't show on hover
|
||||
child: ListTile(
|
||||
onTap: () => onSelected(suggestion),
|
||||
title: Text(
|
||||
commandExample(command),
|
||||
style: const TextStyle(fontFamily: 'RobotoMono'),
|
||||
),
|
||||
subtitle: Text(
|
||||
hint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'emoji') {
|
||||
final label = suggestion['label']!;
|
||||
return Tooltip(
|
||||
message: label,
|
||||
waitDuration: const Duration(days: 1), // don't show on hover
|
||||
child: ListTile(
|
||||
onTap: () => onSelected(suggestion),
|
||||
leading: SizedBox.square(
|
||||
dimension: size,
|
||||
child: Text(
|
||||
suggestion['emoji']!,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'emote') {
|
||||
return ListTile(
|
||||
onTap: () => onSelected(suggestion),
|
||||
leading: MxcImage(
|
||||
// ensure proper ordering ...
|
||||
key: ValueKey(suggestion['name']),
|
||||
uri: suggestion['mxc'] is String
|
||||
? Uri.parse(suggestion['mxc'] ?? '')
|
||||
: null,
|
||||
width: size,
|
||||
height: size,
|
||||
isThumbnail: false,
|
||||
),
|
||||
title: Row(
|
||||
crossAxisAlignment: .center,
|
||||
children: <Widget>[
|
||||
Text(suggestion['name']!),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Opacity(
|
||||
opacity: suggestion['pack_avatar_url'] != null ? 0.8 : 0.5,
|
||||
child: suggestion['pack_avatar_url'] != null
|
||||
? Avatar(
|
||||
mxContent: Uri.tryParse(
|
||||
suggestion.tryGet<String>('pack_avatar_url') ?? '',
|
||||
),
|
||||
name: suggestion.tryGet<String>('pack_display_name'),
|
||||
size: size * 0.9,
|
||||
client: client,
|
||||
)
|
||||
: Text(suggestion['pack_display_name']!),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'user' || suggestion['type'] == 'room') {
|
||||
final url = Uri.parse(suggestion['avatar_url'] ?? '');
|
||||
return ListTile(
|
||||
onTap: () => onSelected(suggestion),
|
||||
leading: Avatar(
|
||||
mxContent: url,
|
||||
name:
|
||||
suggestion.tryGet<String>('displayname') ??
|
||||
suggestion.tryGet<String>('mxid'),
|
||||
size: size,
|
||||
client: client,
|
||||
),
|
||||
title: Text(suggestion['displayname'] ?? suggestion['mxid']!),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
String insertSuggestion(Map<String, String?> suggestion) {
|
||||
final replaceText = controller!.text.substring(
|
||||
0,
|
||||
controller!.selection.baseOffset,
|
||||
);
|
||||
var startText = '';
|
||||
final afterText = replaceText == controller!.text
|
||||
? ''
|
||||
: controller!.text.substring(controller!.selection.baseOffset + 1);
|
||||
var insertText = '';
|
||||
if (suggestion['type'] == 'command') {
|
||||
insertText = '${suggestion['name']!} ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
RegExp(r'^(/\w*)$'),
|
||||
(Match m) => '/$insertText',
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'emoji') {
|
||||
insertText = '${suggestion['emoji']!} ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
suggestion['current_word']!,
|
||||
(Match m) => insertText,
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'emote') {
|
||||
var isUnique = true;
|
||||
final insertEmote = suggestion['name'];
|
||||
final insertPack = suggestion['pack'];
|
||||
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
|
||||
for (final pack in emotePacks.entries) {
|
||||
if (pack.key == insertPack) {
|
||||
continue;
|
||||
}
|
||||
for (final emote in pack.value.images.entries) {
|
||||
if (emote.key == insertEmote) {
|
||||
isUnique = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isUnique) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
insertText = ':${isUnique ? '' : '${insertPack!}~'}$insertEmote: ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
RegExp(r'(\s|^)(:(?:[-\w]+~)?[-\w]+)$'),
|
||||
(Match m) => '${m[1]}$insertText',
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'user') {
|
||||
insertText = '${suggestion['mention']!} ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
RegExp(r'(\s|^)(@[-\w]+)$'),
|
||||
(Match m) => '${m[1]}$insertText',
|
||||
);
|
||||
}
|
||||
if (suggestion['type'] == 'room') {
|
||||
insertText = '${suggestion['mxid']!} ';
|
||||
startText = replaceText.replaceAllMapped(
|
||||
RegExp(r'(\s|^)(#[-\w]+)$'),
|
||||
(Match m) => '${m[1]}$insertText',
|
||||
);
|
||||
}
|
||||
|
||||
return startText + afterText;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Autocomplete<Map<String, String?>>(
|
||||
key: Key('chat_input_field'),
|
||||
focusNode: focusNode,
|
||||
textEditingController: controller,
|
||||
optionsBuilder: getSuggestions,
|
||||
fieldViewBuilder: (context, controller, focusNode, _) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(AppSettings.fontSizeFactor.value),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
readOnly: readOnly,
|
||||
onEditingComplete: () {
|
||||
// To not lose focus on iOS:
|
||||
// https://github.com/krille-chan/fluffychat/issues/2784
|
||||
},
|
||||
contextMenuBuilder: (c, e) => MarkdownContextBuilder(
|
||||
editableTextState: e,
|
||||
controller: controller,
|
||||
),
|
||||
contentInsertionConfiguration: ContentInsertionConfiguration(
|
||||
onContentInserted: (KeyboardInsertedContent content) async {
|
||||
final proceed = await showTrustUserInRoomDialog(context, room);
|
||||
if (!proceed) return;
|
||||
final data = content.data;
|
||||
if (data == null) return;
|
||||
|
||||
final file = MatrixFile(
|
||||
mimeType: content.mimeType,
|
||||
bytes: data,
|
||||
name: content.uri.split('/').last,
|
||||
);
|
||||
room.sendFileEvent(file, shrinkImageMaxDimension: 1600);
|
||||
},
|
||||
),
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
autofocus: autofocus!,
|
||||
inputFormatters: [
|
||||
LengthLimitingTextInputFormatter((maxPDUSize / 3).floor()),
|
||||
],
|
||||
onSubmitted: (text) {
|
||||
// fix for library for now
|
||||
// it sets the types for the callback incorrectly
|
||||
onSubmitted!(text);
|
||||
},
|
||||
maxLength: AppSettings.textMessageMaxLength.value,
|
||||
decoration: decoration,
|
||||
onChanged: (text) {
|
||||
// fix for the library for now
|
||||
// it sets the types for the callback incorrectly
|
||||
onChanged!(text);
|
||||
},
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
),
|
||||
optionsViewBuilder: (c, onSelected, s) {
|
||||
final suggestions = s.toList();
|
||||
return Material(
|
||||
elevation: theme.appBarTheme.scrolledUnderElevation ?? 4,
|
||||
shadowColor: theme.appBarTheme.shadowColor,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
shrinkWrap: true,
|
||||
itemCount: suggestions.length,
|
||||
itemBuilder: (context, i) => buildSuggestion(
|
||||
c,
|
||||
suggestions[i],
|
||||
onSelected,
|
||||
Matrix.of(context).client,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
displayStringForOption: insertSuggestion,
|
||||
optionsViewOpenDirection: OptionsViewOpenDirection.up,
|
||||
);
|
||||
}
|
||||
}
|
||||
104
lib/pages/chat/pinned_events.dart
Normal file
104
lib/pages/chat/pinned_events.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/pages/chat/chat_app_bar_list_tile.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_modal_action_popup.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class PinnedEvents extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const PinnedEvents(this.controller, {super.key});
|
||||
|
||||
Future<void> _displayPinnedEventsDialog(BuildContext context) async {
|
||||
final l10n = L10n.of(context);
|
||||
final eventsResult = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => Future.wait(
|
||||
controller.room.pinnedEventIds.map(
|
||||
(eventId) => controller.room.getEventById(eventId),
|
||||
),
|
||||
),
|
||||
);
|
||||
final events = eventsResult.result;
|
||||
if (events == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final eventId = events.length == 1
|
||||
? events.single?.eventId
|
||||
: await showModalActionPopup<String>(
|
||||
context: context,
|
||||
title: l10n.pin,
|
||||
cancelLabel: l10n.cancel,
|
||||
actions: events
|
||||
.map(
|
||||
(event) => AdaptiveModalAction(
|
||||
value: event?.eventId ?? '',
|
||||
icon: const Icon(Icons.push_pin_outlined),
|
||||
label:
|
||||
event
|
||||
?.calcLocalizedBodyFallback(
|
||||
MatrixLocals(l10n),
|
||||
withSenderNamePrefix: true,
|
||||
hideReply: true,
|
||||
)
|
||||
.trim()
|
||||
.replaceAll('\n', ' ') ??
|
||||
'UNKNOWN',
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (eventId != null) controller.scrollToEventId(eventId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final pinnedEventIds = controller.room.pinnedEventIds;
|
||||
|
||||
if (pinnedEventIds.isEmpty || controller.activeThreadId != null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return FutureBuilder<Event?>(
|
||||
future: controller.room.getEventById(pinnedEventIds.last),
|
||||
builder: (context, snapshot) {
|
||||
final event = snapshot.data;
|
||||
return ChatAppBarListTile(
|
||||
title:
|
||||
event
|
||||
?.calcLocalizedBodyFallback(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
withSenderNamePrefix: true,
|
||||
hideReply: true,
|
||||
)
|
||||
.replaceAll('\n', ' ') ??
|
||||
L10n.of(context).loadingPleaseWait,
|
||||
leading: IconButton(
|
||||
splashRadius: 18,
|
||||
iconSize: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
icon: const Icon(Icons.push_pin),
|
||||
tooltip: L10n.of(context).unpin,
|
||||
onPressed: controller.room.canSendEvent(EventTypes.RoomPinnedEvents)
|
||||
? () => controller.unpinEvent(event!.eventId)
|
||||
: null,
|
||||
),
|
||||
onTap: () => _displayPinnedEventsDialog(context),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
107
lib/pages/chat/recording_input_row.dart
Normal file
107
lib/pages/chat/recording_input_row.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/chat_input_row.dart';
|
||||
import 'package:tatchat/pages/chat/recording_view_model.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class RecordingInputRow extends StatelessWidget {
|
||||
final RecordingViewModelState state;
|
||||
final Future<void> Function(String, int, List<int>, String) onSend;
|
||||
const RecordingInputRow({
|
||||
required this.state,
|
||||
required this.onSend,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
const maxDecibalWidth = 36.0;
|
||||
final time =
|
||||
'${state.duration.inMinutes.toString().padLeft(2, '0')}:${(state.duration.inSeconds % 60).toString().padLeft(2, '0')}';
|
||||
return SizedBox(
|
||||
height: ChatInputRow.height,
|
||||
child: Row(
|
||||
crossAxisAlignment: .center,
|
||||
mainAxisAlignment: .spaceBetween,
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
alignment: .center,
|
||||
width: 48,
|
||||
child: IconButton(
|
||||
tooltip: L10n.of(context).cancel,
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
color: theme.colorScheme.error,
|
||||
onPressed: state.cancel,
|
||||
),
|
||||
),
|
||||
if (state.isPaused)
|
||||
Container(
|
||||
alignment: .center,
|
||||
width: 48,
|
||||
child: IconButton(
|
||||
tooltip: L10n.of(context).resume,
|
||||
icon: const Icon(Icons.play_circle_outline_outlined),
|
||||
onPressed: state.resume,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
alignment: .center,
|
||||
width: 48,
|
||||
child: IconButton(
|
||||
tooltip: L10n.of(context).pause,
|
||||
icon: const Icon(Icons.pause_circle_outline_outlined),
|
||||
onPressed: state.pause,
|
||||
),
|
||||
),
|
||||
Text(time),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const width = 4;
|
||||
return Row(
|
||||
mainAxisSize: .min,
|
||||
mainAxisAlignment: .end,
|
||||
children: state.amplitudeTimeline.reversed
|
||||
.take((constraints.maxWidth / (width + 2)).floor())
|
||||
.toList()
|
||||
.reversed
|
||||
.map(
|
||||
(amplitude) => Container(
|
||||
margin: const EdgeInsets.only(left: 2),
|
||||
width: width.toDouble(),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
height: maxDecibalWidth * (amplitude / 100),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
disabledBackgroundColor: theme.bubbleColor.withAlpha(128),
|
||||
backgroundColor: theme.bubbleColor,
|
||||
foregroundColor: theme.onBubbleColor,
|
||||
),
|
||||
tooltip: L10n.of(context).sendAudio,
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
onPressed: () => state.stopAndSend(onSend),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
222
lib/pages/chat/recording_view_model.dart
Normal file
222
lib/pages/chat/recording_view_model.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path/path.dart' as path_lib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import 'events/audio_player.dart';
|
||||
|
||||
class RecordingViewModel extends StatefulWidget {
|
||||
final Widget Function(BuildContext, RecordingViewModelState) builder;
|
||||
|
||||
const RecordingViewModel({required this.builder, super.key});
|
||||
|
||||
@override
|
||||
RecordingViewModelState createState() => RecordingViewModelState();
|
||||
}
|
||||
|
||||
class RecordingViewModelState extends State<RecordingViewModel> {
|
||||
Timer? _recorderSubscription;
|
||||
Duration duration = Duration.zero;
|
||||
|
||||
bool get isRecording => _audioRecorder != null;
|
||||
|
||||
AudioRecorder? _audioRecorder;
|
||||
final List<double> amplitudeTimeline = [];
|
||||
|
||||
String? fileName;
|
||||
|
||||
bool isPaused = false;
|
||||
|
||||
Future<void> startRecording(Room room) async {
|
||||
room.client.getConfig(); // Preload server file configuration.
|
||||
if (PlatformInfos.isAndroid) {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
if (!mounted) return;
|
||||
if (info.version.sdkInt < 19) {
|
||||
showOkAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).unsupportedAndroidVersion,
|
||||
message: L10n.of(context).unsupportedAndroidVersionLong,
|
||||
okLabel: L10n.of(context).close,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (await AudioRecorder().hasPermission() == false) return;
|
||||
|
||||
final audioRecorder = _audioRecorder ??= AudioRecorder();
|
||||
setState(() {});
|
||||
|
||||
try {
|
||||
final codec =
|
||||
!PlatformInfos
|
||||
.isIOS && // Blocked by https://github.com/llfbandit/record/issues/560
|
||||
await audioRecorder.isEncoderSupported(AudioEncoder.opus)
|
||||
? AudioEncoder.opus
|
||||
: AudioEncoder.aacLc;
|
||||
fileName =
|
||||
'voice_message_${DateTime.now().millisecondsSinceEpoch}.${codec.fileExtension}';
|
||||
String? path;
|
||||
if (!kIsWeb) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
path = path_lib.join(tempDir.path, fileName);
|
||||
}
|
||||
|
||||
final result = await audioRecorder.hasPermission();
|
||||
if (result != true) {
|
||||
if (!mounted) return;
|
||||
showOkAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).oopsSomethingWentWrong,
|
||||
message: L10n.of(context).noPermission,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await WakelockPlus.enable();
|
||||
|
||||
await audioRecorder.start(
|
||||
RecordConfig(
|
||||
bitRate: AppSettings.audioRecordingBitRate.value,
|
||||
sampleRate: AppSettings.audioRecordingSamplingRate.value,
|
||||
numChannels: AppSettings.audioRecordingNumChannels.value,
|
||||
autoGain: AppSettings.audioRecordingAutoGain.value,
|
||||
echoCancel: AppSettings.audioRecordingEchoCancel.value,
|
||||
noiseSuppress: AppSettings.audioRecordingNoiseSuppress.value,
|
||||
encoder: codec,
|
||||
),
|
||||
path: path ?? '',
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => duration = Duration.zero);
|
||||
_subscribe();
|
||||
} catch (e, s) {
|
||||
Logs().w('Unable to start voice message recording', e, s);
|
||||
if (!mounted) return;
|
||||
showOkAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).oopsSomethingWentWrong,
|
||||
message: e.toString(),
|
||||
);
|
||||
setState(_reset);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reset();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
_recorderSubscription?.cancel();
|
||||
_recorderSubscription = Timer.periodic(const Duration(milliseconds: 100), (
|
||||
_,
|
||||
) async {
|
||||
final amplitude = await _audioRecorder!.getAmplitude();
|
||||
var value = 100 + amplitude.current * 2;
|
||||
value = value < 1 ? 1 : value;
|
||||
amplitudeTimeline.add(value);
|
||||
setState(() {
|
||||
duration += const Duration(milliseconds: 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
WakelockPlus.disable();
|
||||
_recorderSubscription?.cancel();
|
||||
_audioRecorder?.stop();
|
||||
_audioRecorder = null;
|
||||
|
||||
fileName = null;
|
||||
duration = Duration.zero;
|
||||
amplitudeTimeline.clear();
|
||||
isPaused = false;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
setState(_reset);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_audioRecorder?.pause();
|
||||
_recorderSubscription?.cancel();
|
||||
setState(() {
|
||||
isPaused = true;
|
||||
});
|
||||
}
|
||||
|
||||
void resume() {
|
||||
_audioRecorder?.resume();
|
||||
_subscribe();
|
||||
setState(() {
|
||||
isPaused = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stopAndSend(
|
||||
Future<void> Function(
|
||||
String path,
|
||||
int duration,
|
||||
List<int> waveform,
|
||||
String fileName,
|
||||
)
|
||||
onSend,
|
||||
) async {
|
||||
_recorderSubscription?.cancel();
|
||||
final path = await _audioRecorder?.stop();
|
||||
|
||||
if (path == null) throw ('Recording failed!');
|
||||
const waveCount = AudioPlayerWidget.wavesCount;
|
||||
final step = amplitudeTimeline.length < waveCount
|
||||
? 1
|
||||
: (amplitudeTimeline.length / waveCount).round();
|
||||
final waveform = <int>[];
|
||||
for (var i = 0; i < amplitudeTimeline.length; i += step) {
|
||||
waveform.add((amplitudeTimeline[i] / 100 * 1024).round());
|
||||
}
|
||||
|
||||
onSend(path, duration.inMilliseconds, waveform, fileName!);
|
||||
|
||||
cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.builder(context, this);
|
||||
}
|
||||
|
||||
extension on AudioEncoder {
|
||||
String get fileExtension {
|
||||
switch (this) {
|
||||
case AudioEncoder.aacLc:
|
||||
case AudioEncoder.aacEld:
|
||||
case AudioEncoder.aacHe:
|
||||
return 'm4a';
|
||||
case AudioEncoder.opus:
|
||||
return 'ogg';
|
||||
case AudioEncoder.wav:
|
||||
return 'wav';
|
||||
case AudioEncoder.amrNb:
|
||||
case AudioEncoder.amrWb:
|
||||
case AudioEncoder.flac:
|
||||
case AudioEncoder.pcm16bits:
|
||||
throw UnsupportedError('Not yet used');
|
||||
}
|
||||
}
|
||||
}
|
||||
95
lib/pages/chat/reply_display.dart
Normal file
95
lib/pages/chat/reply_display.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../config/themes.dart';
|
||||
import 'chat.dart';
|
||||
import 'events/reply_content.dart';
|
||||
|
||||
class ReplyDisplay extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
const ReplyDisplay(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: controller.editEvent != null || controller.replyEvent != null
|
||||
? Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: IconButton(
|
||||
tooltip: L10n.of(context).close,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: controller.cancelReplyEventAction,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: controller.replyEvent != null
|
||||
? ReplyContent(
|
||||
controller.replyEvent!,
|
||||
timeline: controller.timeline,
|
||||
)
|
||||
: _EditContent(
|
||||
controller.editEvent?.getDisplayEvent(
|
||||
controller.timeline!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditContent extends StatelessWidget {
|
||||
final Event? event;
|
||||
|
||||
const _EditContent(this.event);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final event = this.event;
|
||||
if (event == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.edit, color: theme.colorScheme.primary),
|
||||
Container(width: 15.0),
|
||||
Text(
|
||||
event
|
||||
.calcLocalizedBodyFallback(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
withSenderNamePrefix: false,
|
||||
hideReply: true,
|
||||
)
|
||||
.trim()
|
||||
.replaceAll('\n', ' '),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(color: theme.textTheme.bodyMedium!.color),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/pages/chat/seen_by_row.dart
Normal file
94
lib/pages/chat/seen_by_row.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class SeenByRow extends StatelessWidget {
|
||||
final Event event;
|
||||
const SeenByRow({super.key, required this.event});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
const maxAvatars = 7;
|
||||
return StreamBuilder(
|
||||
stream: event.room.client.onSync.stream.where(
|
||||
(syncUpdate) =>
|
||||
syncUpdate.rooms?.join?[event.room.id]?.ephemeral?.any(
|
||||
(ephemeral) => ephemeral.type == 'm.receipt',
|
||||
) ??
|
||||
false,
|
||||
),
|
||||
builder: (context, asyncSnapshot) {
|
||||
final seenByUsers = event.receipts
|
||||
.map((r) => r.user)
|
||||
.where(
|
||||
(user) =>
|
||||
user.id != event.room.client.userID &&
|
||||
user.id != event.senderId,
|
||||
)
|
||||
.toList();
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedContainer(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.maxTimelineWidth,
|
||||
),
|
||||
height: seenByUsers.isEmpty ? 0 : 24,
|
||||
duration: seenByUsers.isEmpty
|
||||
? Duration.zero
|
||||
: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
alignment: event.senderId == Matrix.of(context).client.userID
|
||||
? Alignment.topRight
|
||||
: Alignment.topLeft,
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 4,
|
||||
top: 1,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
...(seenByUsers.length > maxAvatars
|
||||
? seenByUsers.sublist(0, maxAvatars)
|
||||
: seenByUsers)
|
||||
.map(
|
||||
(user) => Avatar(
|
||||
mxContent: user.avatarUrl,
|
||||
name: user.calcDisplayname(),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
if (seenByUsers.length > maxAvatars)
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+${seenByUsers.length - maxAvatars}',
|
||||
style: const TextStyle(fontSize: 9),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
1205
lib/pages/chat/send_file_dialog.dart
Normal file
1205
lib/pages/chat/send_file_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
136
lib/pages/chat/send_location_dialog.dart
Normal file
136
lib/pages/chat/send_location_dialog.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/events/map_bubble.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class SendLocationDialog extends StatefulWidget {
|
||||
final Room room;
|
||||
|
||||
const SendLocationDialog({required this.room, super.key});
|
||||
|
||||
@override
|
||||
SendLocationDialogState createState() => SendLocationDialogState();
|
||||
}
|
||||
|
||||
class SendLocationDialogState extends State<SendLocationDialog> {
|
||||
bool disabled = false;
|
||||
bool denied = false;
|
||||
bool isSending = false;
|
||||
Position? position;
|
||||
Object? error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
requestLocation();
|
||||
}
|
||||
|
||||
Future<void> requestLocation() async {
|
||||
if (!(await Geolocator.isLocationServiceEnabled())) {
|
||||
setState(() => disabled = true);
|
||||
return;
|
||||
}
|
||||
var permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
setState(() => denied = true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
setState(() => denied = true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Position position;
|
||||
try {
|
||||
position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
timeLimit: Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
} on TimeoutException {
|
||||
position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.medium,
|
||||
timeLimit: Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
}
|
||||
setState(() => this.position = position);
|
||||
} catch (e) {
|
||||
setState(() => error = e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendAction() async {
|
||||
setState(() => isSending = true);
|
||||
final body =
|
||||
'https://www.openstreetmap.org/?mlat=${position!.latitude}&mlon=${position!.longitude}#map=16/${position!.latitude}/${position!.longitude}';
|
||||
final uri =
|
||||
'geo:${position!.latitude},${position!.longitude};u=${position!.accuracy}';
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => widget.room.sendLocation(body, uri),
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget contentWidget;
|
||||
if (position != null) {
|
||||
contentWidget = MapBubble(
|
||||
latitude: position!.latitude,
|
||||
longitude: position!.longitude,
|
||||
);
|
||||
} else if (disabled) {
|
||||
contentWidget = Text(L10n.of(context).locationDisabledNotice);
|
||||
} else if (denied) {
|
||||
contentWidget = Text(L10n.of(context).locationPermissionDeniedNotice);
|
||||
} else if (error != null) {
|
||||
contentWidget = Text(
|
||||
L10n.of(context).errorObtainingLocation(error.toString()),
|
||||
);
|
||||
} else {
|
||||
contentWidget = Row(
|
||||
mainAxisSize: .min,
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
const CupertinoActivityIndicator(),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).obtainingLocation),
|
||||
],
|
||||
);
|
||||
}
|
||||
return AlertDialog.adaptive(
|
||||
title: Text(L10n.of(context).shareLocation),
|
||||
content: contentWidget,
|
||||
actions: [
|
||||
AdaptiveDialogAction(
|
||||
onPressed: Navigator.of(context, rootNavigator: false).pop,
|
||||
child: Text(L10n.of(context).cancel),
|
||||
),
|
||||
if (position != null)
|
||||
AdaptiveDialogAction(
|
||||
onPressed: isSending ? null : sendAction,
|
||||
child: Text(L10n.of(context).send),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
174
lib/pages/chat/start_poll_bottom_sheet.dart
Normal file
174
lib/pages/chat/start_poll_bottom_sheet.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/trust_user_key_dialog.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class StartPollBottomSheet extends StatefulWidget {
|
||||
final Room room;
|
||||
const StartPollBottomSheet({required this.room, super.key});
|
||||
|
||||
@override
|
||||
State<StartPollBottomSheet> createState() => _StartPollBottomSheetState();
|
||||
}
|
||||
|
||||
class _StartPollBottomSheetState extends State<StartPollBottomSheet> {
|
||||
final TextEditingController _bodyController = TextEditingController();
|
||||
bool _allowMultipleAnswers = false;
|
||||
final List<TextEditingController> _answers = [
|
||||
TextEditingController(),
|
||||
TextEditingController(),
|
||||
];
|
||||
PollKind _pollKind = PollKind.disclosed;
|
||||
|
||||
bool _canCreate = false;
|
||||
|
||||
bool isLoading = false;
|
||||
|
||||
String? _txid;
|
||||
|
||||
Future<void> _createPoll() async {
|
||||
final proceed = await showTrustUserInRoomDialog(context, widget.room);
|
||||
if (!proceed || !mounted) return;
|
||||
try {
|
||||
var id = 0;
|
||||
_txid ??= widget.room.client.generateUniqueTransactionId();
|
||||
await widget.room.startPoll(
|
||||
question: _bodyController.text.trim(),
|
||||
answers: _answers
|
||||
.map(
|
||||
(answerController) => PollAnswer(
|
||||
id: (++id).toString(),
|
||||
mText: answerController.text.trim(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
kind: _pollKind,
|
||||
maxSelections: _allowMultipleAnswers ? _answers.length : 1,
|
||||
txid: _txid,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
} catch (e, s) {
|
||||
Logs().w('Unable to create poll', e, s);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
|
||||
}
|
||||
}
|
||||
|
||||
void _updateCanCreate([_]) {
|
||||
final newCanCreate =
|
||||
_bodyController.text.trim().isNotEmpty &&
|
||||
!_answers.any((controller) => controller.text.trim().isEmpty);
|
||||
if (_canCreate != newCanCreate) {
|
||||
setState(() {
|
||||
_canCreate = newCanCreate;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bodyController.dispose();
|
||||
for (final answerController in _answers) {
|
||||
answerController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const maxAnswers = 10;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: CloseButton(onPressed: Navigator.of(context).pop),
|
||||
title: Text(L10n.of(context).startPoll),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _bodyController,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
maxLength: 1024,
|
||||
onChanged: _updateCanCreate,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context).pollQuestion,
|
||||
counter: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
..._answers.map(
|
||||
(answerController) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: TextField(
|
||||
controller: answerController,
|
||||
onChanged: _updateCanCreate,
|
||||
maxLength: 64,
|
||||
decoration: InputDecoration(
|
||||
counter: const SizedBox.shrink(),
|
||||
hintText: L10n.of(context).answerOption,
|
||||
suffixIcon: _answers.length == 2
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.cancel_outlined),
|
||||
onPressed: () => setState(() {
|
||||
_answers.remove(answerController..dispose());
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.add_outlined),
|
||||
onPressed: _answers.length < maxAnswers
|
||||
? () => setState(() {
|
||||
_answers.add(TextEditingController());
|
||||
})
|
||||
: null,
|
||||
label: Text(L10n.of(context).addAnswerOption),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Switch.adaptive(
|
||||
value: _pollKind == PollKind.disclosed,
|
||||
onChanged: (allow) => setState(() {
|
||||
_pollKind = allow ? PollKind.disclosed : PollKind.undisclosed;
|
||||
}),
|
||||
),
|
||||
title: Text(L10n.of(context).answersVisible),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Switch.adaptive(
|
||||
value: _allowMultipleAnswers,
|
||||
onChanged: (allow) => setState(() {
|
||||
_allowMultipleAnswers = allow;
|
||||
}),
|
||||
),
|
||||
title: Text(L10n.of(context).allowMultipleAnswers),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: !isLoading && _canCreate ? _createPoll : null,
|
||||
child: isLoading
|
||||
? const LinearProgressIndicator()
|
||||
: Text(L10n.of(context).startPoll),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
187
lib/pages/chat/sticker_picker_dialog.dart
Normal file
187
lib/pages/chat/sticker_picker_dialog.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../widgets/avatar.dart';
|
||||
|
||||
class StickerPickerDialog extends StatefulWidget {
|
||||
final Room room;
|
||||
final ImagePackUsage usage;
|
||||
final void Function(ImagePackImageContent) onSelected;
|
||||
|
||||
const StickerPickerDialog({
|
||||
required this.onSelected,
|
||||
required this.room,
|
||||
this.usage = ImagePackUsage.sticker,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
StickerPickerDialogState createState() => StickerPickerDialogState();
|
||||
}
|
||||
|
||||
class StickerPickerDialogState extends State<StickerPickerDialog> {
|
||||
String? searchFilter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final stickerPacks = widget.room.getImagePacks(widget.usage);
|
||||
final packSlugs = stickerPacks.keys.toList();
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
primary: false,
|
||||
toolbarHeight: 72,
|
||||
scrolledUnderElevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
title: TextField(
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.secondaryContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
hintText: L10n.of(context).search,
|
||||
hintStyle: TextStyle(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
prefixIcon: const Icon(Icons.search_outlined),
|
||||
),
|
||||
onChanged: (s) => setState(() => searchFilter = s),
|
||||
),
|
||||
),
|
||||
if (packSlugs.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(L10n.of(context).noEmotesFound),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => UrlLauncher(
|
||||
context,
|
||||
AppConfig.howDoIGetStickersTutorial,
|
||||
).launchUrl(),
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
label: Text(L10n.of(context).discover),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList.builder(
|
||||
itemCount: packSlugs.length,
|
||||
itemBuilder: (BuildContext context, int packIndex) {
|
||||
final pack = stickerPacks[packSlugs[packIndex]]!;
|
||||
final filteredImagePackImageEntried = pack.images.entries
|
||||
.toList();
|
||||
if (searchFilter?.isNotEmpty ?? false) {
|
||||
filteredImagePackImageEntried.removeWhere(
|
||||
(e) =>
|
||||
!(e.key.toLowerCase().contains(
|
||||
searchFilter!.toLowerCase(),
|
||||
) ||
|
||||
(e.value.body?.toLowerCase().contains(
|
||||
searchFilter!.toLowerCase(),
|
||||
) ??
|
||||
false)),
|
||||
);
|
||||
}
|
||||
final imageKeys = filteredImagePackImageEntried
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
if (imageKeys.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final packName =
|
||||
pack.pack.displayName ?? packSlugs[packIndex];
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
if (packIndex != 0) const SizedBox(height: 20),
|
||||
if (packName != 'user')
|
||||
ListTile(
|
||||
leading: Avatar(
|
||||
mxContent: pack.pack.avatarUrl,
|
||||
name: packName,
|
||||
client: widget.room.client,
|
||||
),
|
||||
title: Text(packName),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
GridView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: imageKeys.length,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 84,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (BuildContext context, int imageIndex) {
|
||||
final image = pack.images[imageKeys[imageIndex]]!;
|
||||
return Tooltip(
|
||||
message: image.body ?? imageKeys[imageIndex],
|
||||
child: InkWell(
|
||||
radius: AppConfig.borderRadius,
|
||||
key: ValueKey(image.url.toString()),
|
||||
onTap: () {
|
||||
// copy the image
|
||||
final imageCopy =
|
||||
ImagePackImageContent.fromJson(
|
||||
image.toJson().copy(),
|
||||
);
|
||||
// set the body, if it doesn't exist, to the key
|
||||
imageCopy.body ??= imageKeys[imageIndex];
|
||||
widget.onSelected(imageCopy);
|
||||
},
|
||||
child: AbsorbPointer(
|
||||
absorbing: true,
|
||||
child: MxcImage(
|
||||
uri: image.url,
|
||||
fit: BoxFit.contain,
|
||||
width: 128,
|
||||
height: 128,
|
||||
animated: true,
|
||||
isThumbnail: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
163
lib/pages/chat/trust_user_key_dialog.dart
Normal file
163
lib/pages/chat/trust_user_key_dialog.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/key_verification/key_verification_dialog.dart';
|
||||
import 'package:tatchat/utils/beautify_string_extension.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/dialog_text_field.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
Future<bool> showTrustUserInRoomDialog(BuildContext context, Room room) async {
|
||||
if (!room.encrypted) return true;
|
||||
|
||||
final users = await room.requestParticipants();
|
||||
if (!context.mounted) return false;
|
||||
|
||||
users.removeWhere((user) {
|
||||
if (user.id == room.client.userID) return true;
|
||||
final keys = room.client.userDeviceKeys[user.id];
|
||||
final masterKey = keys?.masterKey;
|
||||
|
||||
if (keys == null ||
|
||||
masterKey == null ||
|
||||
masterKey.verified ||
|
||||
masterKey.trustOnFirstUseSince != null) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (users.isEmpty) return true;
|
||||
|
||||
final l10n = L10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final action = await showAdaptiveDialog<_Action>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Center(
|
||||
child: Icon(
|
||||
Icons.lock_outlined,
|
||||
size: 32,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 128),
|
||||
child: SelectionArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Text(
|
||||
users.length == 1
|
||||
? l10n.messageCanOnlyBeReadByUser
|
||||
: l10n.messageCanOnlyBeReadByUsers,
|
||||
style: TextStyle(fontSize: 16),
|
||||
textAlign: .center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (final user in users) ...[
|
||||
Row(
|
||||
children: [
|
||||
Avatar(
|
||||
name: user.calcDisplayname(),
|
||||
mxContent: user.avatarUrl,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
user.calcDisplayname(),
|
||||
style: theme.textTheme.labelSmall,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final controller = TextEditingController(
|
||||
text: l10n.publicKey(
|
||||
room
|
||||
.client
|
||||
.userDeviceKeys[user.id]
|
||||
?.masterKey
|
||||
?.publicKey
|
||||
?.beautifiedOneLine ??
|
||||
'???',
|
||||
),
|
||||
);
|
||||
return DialogTextField(
|
||||
controller: controller,
|
||||
textStyle: theme.textTheme.labelSmall,
|
||||
readOnly: true,
|
||||
maxLines: 3,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
AdaptiveDialogAction(
|
||||
autofocus: true,
|
||||
bigButtons: true,
|
||||
onPressed: () {
|
||||
for (final user in users) {
|
||||
room.client.userDeviceKeys[user.id]?.masterKey?.trustOnFirstUse();
|
||||
}
|
||||
Navigator.of(context).pop(_Action.allow);
|
||||
},
|
||||
child: Text(L10n.of(context).continueText),
|
||||
),
|
||||
if (room.isDirectChat)
|
||||
AdaptiveDialogAction(
|
||||
bigButtons: true,
|
||||
onPressed: () => Navigator.of(context).pop(_Action.verification),
|
||||
child: Text(L10n.of(context).interactiveVerification),
|
||||
),
|
||||
AdaptiveDialogAction(
|
||||
bigButtons: true,
|
||||
onPressed: () => Navigator.of(context).pop(_Action.deny),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (action == null) return false;
|
||||
|
||||
switch (action) {
|
||||
case _Action.allow:
|
||||
for (final user in users) {
|
||||
room.client.userDeviceKeys[user.id]?.masterKey?.trustOnFirstUse();
|
||||
}
|
||||
case _Action.deny:
|
||||
return false;
|
||||
case _Action.verification:
|
||||
final req = await room.client.userDeviceKeys[room.directChatMatrixID]
|
||||
?.startVerification();
|
||||
if (req == null) return false;
|
||||
if (!context.mounted) return false;
|
||||
final success = await KeyVerificationDialog(request: req).show(context);
|
||||
return success == true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
enum _Action { allow, deny, verification }
|
||||
103
lib/pages/chat/typing_indicators.dart
Normal file
103
lib/pages/chat/typing_indicators.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/typing_animation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class TypingIndicators extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
const TypingIndicators(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
const avatarSize = Avatar.defaultSize / 2;
|
||||
|
||||
return StreamBuilder<Object>(
|
||||
stream: controller.room.client.onSync.stream.where(
|
||||
(syncUpdate) =>
|
||||
syncUpdate.rooms?.join?[controller.room.id]?.ephemeral?.any(
|
||||
(ephemeral) => ephemeral.type == 'm.typing',
|
||||
) ??
|
||||
false,
|
||||
),
|
||||
builder: (context, _) {
|
||||
final typingUsers = controller.room.typingUsers
|
||||
..removeWhere((u) => u.stateKey == Matrix.of(context).client.userID);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
alignment: Alignment.center,
|
||||
child: AnimatedContainer(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.maxTimelineWidth,
|
||||
),
|
||||
height: typingUsers.isEmpty ? 0 : avatarSize + 8,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
alignment:
|
||||
controller.timeline!.events.isNotEmpty &&
|
||||
controller.timeline!.events.first.senderId ==
|
||||
Matrix.of(context).client.userID
|
||||
? Alignment.topRight
|
||||
: Alignment.topLeft,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
height: avatarSize,
|
||||
width: Avatar.defaultSize,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (typingUsers.isNotEmpty)
|
||||
Avatar(
|
||||
size: avatarSize,
|
||||
mxContent: typingUsers.first.avatarUrl,
|
||||
name: typingUsers.first.calcDisplayname(),
|
||||
),
|
||||
if (typingUsers.length == 2)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Avatar(
|
||||
size: avatarSize,
|
||||
mxContent: typingUsers.length == 2
|
||||
? typingUsers.last.avatarUrl
|
||||
: null,
|
||||
name: typingUsers.length == 2
|
||||
? typingUsers.last.calcDisplayname()
|
||||
: '+${typingUsers.length - 1}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: typingUsers.isEmpty ? null : const TypingAnimation(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/pages/chat/utd_dialog.dart
Normal file
76
lib/pages/chat/utd_dialog.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/event_info_dialog.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class UtdDialog extends StatelessWidget {
|
||||
final Event event;
|
||||
const UtdDialog(this.event, {super.key});
|
||||
|
||||
static Future<void> show(BuildContext context, Event event) async {
|
||||
event.requestKey();
|
||||
final action = await showAdaptiveDialog<_UtdDialogAction>(
|
||||
barrierDismissible: true,
|
||||
context: context,
|
||||
builder: (_) => UtdDialog(event),
|
||||
);
|
||||
if (action == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
switch (action) {
|
||||
case _UtdDialogAction.info:
|
||||
event.showInfoDialog(context);
|
||||
case _UtdDialogAction.redact:
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => event.redactEvent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog.adaptive(
|
||||
title: Text(L10n.of(context).whyIsThisMessageEncrypted),
|
||||
content: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 200),
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
event.calcLocalizedBodyFallback(MatrixLocals(L10n.of(context))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
AdaptiveDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(_UtdDialogAction.info),
|
||||
child: Text(L10n.of(context).messageInfo),
|
||||
),
|
||||
if (event.canRedact)
|
||||
AdaptiveDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(_UtdDialogAction.redact),
|
||||
child: Text(
|
||||
L10n.of(context).redactMessage,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
AdaptiveDialogAction(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: Text(L10n.of(context).close),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _UtdDialogAction { info, redact }
|
||||
23
lib/pages/chat/utils/web_file_to_x_file.dart
Normal file
23
lib/pages/chat/utils/web_file_to_x_file.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:universal_html/universal_html.dart' as web;
|
||||
|
||||
Future<XFile> webToXFile(web.File file) async {
|
||||
final reader = web.FileReader();
|
||||
reader.readAsArrayBuffer(file);
|
||||
await reader.onLoad.first;
|
||||
|
||||
return XFile(
|
||||
file.relativePath ?? file.name,
|
||||
name: file.name,
|
||||
mimeType: file.type,
|
||||
bytes: reader.result as Uint8List,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user