Files
Warp/apps/mobile/lib/widgets/message_extras.dart
T
tajniak81andClaude Opus 5.5 12550c4078 Mobile: calls in the background, screen sharing, read receipts per thread
- Calls keep going with the app in the background: a foreground service
  (CallService.kt, driven from lib/matrix/call_service.dart) declares the
  microphone, the camera while it's on and the screen while it's shared.
  Its notification (CallStyle) shows the call's length, opens the call
  and hangs up. Swiping the app away ends the call.
- Screen sharing on Android: consent, then media projection on the
  service, then LiveKit publishes a screen-share track. A shared screen
  replaces the sharer's camera on their tile, shown whole and unmirrored;
  stopping it from Android's controls stops the share.
- Read receipts per thread (MSC3771): an open thread sends receipts with
  thread_id, "Read" under replies comes from receipts in the thread, and
  threads with unread replies get a dot on "N replies" and in the list.

Not tested on a device yet. README updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:22:56 +02:00

377 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../matrix/thread_receipts.dart';
import '../theme.dart';
import 'avatar.dart';
const _i18n = MatrixDefaultLocalizations();
/// The reactions offered with one tap.
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
/// The key of a reaction event: the emoji (or text) it reacts with.
String? reactionKey(Event reaction) => reaction.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key');
/// The user's own reaction with [key] to [event], if any.
Event? myReaction(Event event, Timeline timeline, String key) => event
.aggregatedEvents(timeline, RelationshipTypes.reaction)
.where(
(r) =>
r.senderId == event.room.client.userID &&
!r.redacted &&
reactionKey(r) == key,
)
.firstOrNull;
/// Reacts to [event] with [key], or takes the reaction back if it's there.
Future<void> toggleReaction(Event event, Timeline timeline, String key) async {
final mine = myReaction(event, timeline, key);
if (mine != null) {
await mine.redactEvent();
} else {
await event.room.sendReaction(event.eventId, key);
}
}
Future<String?> askReaction(BuildContext context) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (dialog) => AlertDialog(
title: const Text('React with'),
content: TextField(
controller: controller,
autofocus: true,
maxLength: 16,
decoration: const InputDecoration(hintText: 'An emoji, or a word'),
onSubmitted: (v) =>
Navigator.pop(dialog, v.trim().isEmpty ? null : v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialog),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final v = controller.text.trim();
Navigator.pop(dialog, v.isEmpty ? null : v);
},
child: const Text('React'),
),
],
),
);
}
class ReactionButton extends StatelessWidget {
const ReactionButton({
super.key,
required this.emoji,
required this.selected,
required this.onTap,
});
final String emoji;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = context.warp;
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? c.tealSoft : c.surfaceSunken,
),
child: Text(emoji, style: const TextStyle(fontSize: 22)),
),
);
}
}
/// The reactions under a message, one chip per key with its count. Tapping
/// one adds or takes back the user's own; a long press says who reacted.
class Reactions extends StatelessWidget {
const Reactions({super.key, required this.event, required this.timeline});
final Event event;
final Timeline timeline;
@override
Widget build(BuildContext context) {
final reactions = event
.aggregatedEvents(timeline, RelationshipTypes.reaction)
.where((r) => !r.redacted && reactionKey(r) != null)
.toList();
if (reactions.isEmpty) return const SizedBox.shrink();
final me = event.room.client.userID;
final byKey = <String, List<Event>>{};
for (final r in reactions) {
byKey.putIfAbsent(reactionKey(r)!, () => []).add(r);
}
final keys = byKey.keys.toList()
..sort((a, b) => byKey[b]!.length.compareTo(byKey[a]!.length));
final c = context.warp;
final text = Theme.of(context).textTheme;
final canReact =
event.status.isSent && event.room.canSendEvent(EventTypes.Reaction);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
for (final key in keys)
_ReactionChip(
label: '$key ${byKey[key]!.length}',
mine: byKey[key]!.any((r) => r.senderId == me),
colours: c,
style: text.labelMedium,
onTap: !canReact
? null
: () => toggleReaction(event, timeline, key).catchError((
Object e,
) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(describeError(e))),
);
}
}),
onLongPress: () => _showReactors(context, key, byKey[key]!),
),
],
),
);
}
void _showReactors(BuildContext context, String key, List<Event> list) {
showModalBottomSheet<void>(
context: context,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(key, style: const TextStyle(fontSize: 32)),
for (final r in list)
ListTile(
leading: Avatar(
name: r.senderFromMemoryOrFallback.calcDisplayname(),
seed: r.senderId,
mxc: r.senderFromMemoryOrFallback.avatarUrl,
size: 32,
),
title: Text(r.senderFromMemoryOrFallback.calcDisplayname()),
),
const SizedBox(height: 8),
],
),
),
);
}
}
class _ReactionChip extends StatelessWidget {
const _ReactionChip({
required this.label,
required this.mine,
required this.colours,
required this.style,
required this.onTap,
required this.onLongPress,
});
final String label;
final bool mine;
final WarpColors colours;
final TextStyle? style;
final VoidCallback? onTap;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context) {
final c = colours;
return Material(
color: mine ? c.tealSoft : c.surfaceSunken,
shape: StadiumBorder(
side: BorderSide(color: mine ? c.tealStrong : c.line),
),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: Text(
label,
style: style?.copyWith(color: mine ? c.tealText : c.ink),
),
),
),
);
}
}
/// The message a reply answers, quoted above the reply: who wrote it and
/// its first line.
class ReplyPreview extends StatelessWidget {
const ReplyPreview({super.key, required this.event, required this.timeline});
final Event event;
final Timeline timeline;
@override
Widget build(BuildContext context) {
return FutureBuilder<Event?>(
future: event.getReplyEvent(timeline),
builder: (context, snapshot) {
final original = snapshot.data;
return QuoteBlock(
sender: original?.senderFromMemoryOrFallback.calcDisplayname(),
text: original == null
? (snapshot.connectionState == ConnectionState.done
? 'Message not available'
: '…')
: quoteText(original.getDisplayEvent(timeline)),
);
},
);
}
}
/// One line describing a message, for a quote or the reply bar.
String quoteText(Event event) {
if (event.redacted) return 'Message deleted';
if (event.type == EventTypes.Encrypted) return 'Encrypted message';
return switch (event.messageType) {
MessageTypes.Image => '🖼 Image',
MessageTypes.Sticker => 'Sticker',
MessageTypes.Video => '🎬 Video',
MessageTypes.Audio => '🎵 Audio',
MessageTypes.File => '📄 ${event.body}',
_ =>
event.calcLocalizedBodyFallback(_i18n, hideReply: true).split('\n').first,
};
}
/// A quoted message: a teal bar, the sender, a line of text.
class QuoteBlock extends StatelessWidget {
const QuoteBlock({super.key, required this.sender, required this.text});
final String? sender;
final String text;
@override
Widget build(BuildContext context) {
final c = context.warp;
final style = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 4),
decoration: BoxDecoration(
color: c.surface.withValues(alpha: 0.6),
border: Border(left: BorderSide(color: c.tealStrong, width: 3)),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (sender != null)
Text(
sender!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.labelMedium?.copyWith(
color: c.tealText,
fontWeight: FontWeight.w600,
),
),
Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: style.bodySmall?.copyWith(color: c.inkMuted),
),
],
),
);
}
}
/// Under a message that starts a thread: how many replies, opening it;
/// a dot when it has replies you haven't read.
class ThreadSummary extends StatelessWidget {
const ThreadSummary({
super.key,
required this.event,
required this.timeline,
required this.onTap,
});
final Event event;
final Timeline timeline;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final count = threadReplyCount(event, timeline);
if (count == 0) return const SizedBox.shrink();
final c = context.warp;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, size: 16, color: c.tealText),
const SizedBox(width: 4),
Text(
count == 1 ? '1 reply' : '$count replies',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: c.tealText,
fontWeight: FontWeight.w600,
),
),
if (threadUnread(event, timeline)) ...[
const SizedBox(width: 6),
Icon(
Icons.circle,
size: 8,
color: c.tealText,
semanticLabel: 'Unread',
),
],
],
),
),
),
);
}
}
/// How many replies a thread root has: the server's count from the event's
/// bundled relations, or the replies loaded here if that is more (some came
/// in since the root was fetched).
int threadReplyCount(Event root, Timeline timeline) {
final bundled =
root.unsigned
?.tryGetMap<String, Object?>('m.relations')
?.tryGetMap<String, Object?>(RelationshipTypes.thread)
?.tryGet<int>('count') ??
0;
final loaded = root
.aggregatedEvents(timeline, RelationshipTypes.thread)
.length;
return bundled > loaded ? bundled : loaded;
}