Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

1665
lib/pages/chat/chat.dart Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View 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 '';
}
}

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View 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('<', '&lt;').replaceAll('>', '&gt;');
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),
),
),
);
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

View 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 }

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

View 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 }

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