feat: use the same widget to render hidden tokens in messages and chat list item subtitles (#1356)

This commit is contained in:
ggurdin 2025-01-06 10:56:57 -05:00 committed by GitHub
parent 0203aaf209
commit d53067583d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 323 additions and 291 deletions

View file

@ -5,7 +5,6 @@ import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/room_status_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
@ -307,71 +306,78 @@ class ChatListItem extends StatelessWidget {
maxLines: 1,
softWrap: false,
)
: FutureBuilder(
key: ValueKey(
'${lastEvent?.eventId}_${lastEvent?.type}',
),
// #Pangea
future: room.lastEvent != null
? GetChatListItemSubtitle().getSubtitle(
L10n.of(context),
room.lastEvent,
MatrixState.pangeaController,
)
: Future.value(
L10n.of(context).emptyChat,
),
// future: needLastEventSender
// ? lastEvent.calcLocalizedBody(
// MatrixLocals(L10n.of(context)),
// hideReply: true,
// hideEdit: true,
// plaintextBody: true,
// removeMarkdown: true,
// withSenderNamePrefix:
// (!isDirectChat ||
// directChatMatrixId !=
// room.lastEvent?.senderId),
// )
// : null,
// #Pangea
: room.lastEvent != null
? ChatListItemSubtitle(
event: room.lastEvent,
style: TextStyle(
fontWeight:
unread || room.hasNewMessages
? FontWeight.bold
: null,
color:
theme.colorScheme.onSurfaceVariant,
),
)
// Pangea#
initialData:
lastEvent?.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
hideReply: true,
hideEdit: true,
plaintextBody: true,
removeMarkdown: true,
withSenderNamePrefix: (!isDirectChat ||
directChatMatrixId !=
room.lastEvent?.senderId),
),
builder: (context, snapshot) => Text(
room.membership == Membership.invite
? isDirectChat
? L10n.of(context).invitePrivateChat
// #Pangea
// : L10n.of(context).inviteGroupChat
: L10n.of(context).inviteChat
// Pangea#
: snapshot.data ??
L10n.of(context).emptyChat,
softWrap: false,
maxLines:
room.notificationCount >= 1 ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: unread || room.hasNewMessages
? FontWeight.bold
: FutureBuilder(
key: ValueKey(
'${lastEvent?.eventId}_${lastEvent?.type}',
),
future: needLastEventSender
? lastEvent.calcLocalizedBody(
MatrixLocals(L10n.of(context)),
hideReply: true,
hideEdit: true,
plaintextBody: true,
removeMarkdown: true,
withSenderNamePrefix:
(!isDirectChat ||
directChatMatrixId !=
room.lastEvent
?.senderId),
)
: null,
color: theme.colorScheme.onSurfaceVariant,
decoration:
room.lastEvent?.redacted == true
? TextDecoration.lineThrough
: null,
initialData:
lastEvent?.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
hideReply: true,
hideEdit: true,
plaintextBody: true,
removeMarkdown: true,
withSenderNamePrefix: (!isDirectChat ||
directChatMatrixId !=
room.lastEvent?.senderId),
),
builder: (context, snapshot) => Text(
room.membership == Membership.invite
? isDirectChat
? L10n.of(context)
.invitePrivateChat
// #Pangea
// : L10n.of(context).inviteGroupChat
: L10n.of(context).inviteChat
// Pangea#
: snapshot.data ??
L10n.of(context).emptyChat,
softWrap: false,
maxLines:
room.notificationCount >= 1 ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight:
unread || room.hasNewMessages
? FontWeight.bold
: null,
color: theme
.colorScheme.onSurfaceVariant,
decoration:
room.lastEvent?.redacted == true
? TextDecoration.lineThrough
: null,
),
),
),
),
),
),
const SizedBox(width: 8),
AnimatedContainer(

View file

@ -1,151 +1,119 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_token_text.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import '../../utils/matrix_sdk_extensions/matrix_locals.dart';
class GetChatListItemSubtitle {
final List<String> hideContentKeys = [
ModelKey.transcription,
];
class ChatListItemSubtitle extends StatelessWidget {
final Event? event;
final TextStyle style;
String _constructTokens(
List<PangeaToken> tokens,
List<PangeaToken> hiddenTokens,
) {
String result = "";
int currentPosition = 0;
for (final token in tokens) {
if (token.text.offset > currentPosition) {
result += " " * (token.text.offset - currentPosition);
currentPosition = token.text.offset;
}
const ChatListItemSubtitle({
super.key,
required this.event,
required this.style,
});
if (hiddenTokens.contains(token)) {
result += "_" * token.text.length;
} else {
result += token.text.content;
}
currentPosition += token.text.length;
}
return result;
bool _showPangeaContent(Event event) {
return MatrixState.pangeaController.languageController.languagesSet &&
!event.redacted &&
event.type == EventTypes.Message &&
event.messageType == MessageTypes.Text;
}
Future<String> getSubtitle(
L10n l10n,
Event? event,
PangeaController pangeaController,
Future<MessageEventAndTokens> _getPangeaMessageEvent(
final Event event,
) async {
if (event == null) return l10n.emptyChat;
try {
if (!pangeaController.languageController.languagesSet ||
event.redacted ||
event.type != EventTypes.Message ||
event.messageType != MessageTypes.Text) {
return event.calcLocalizedBody(
MatrixLocals(l10n),
final Timeline timeline = event.room.timeline != null
? event.room.timeline!
: await event.room.getTimeline();
final pangeaMessageEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: event.senderId == event.room.client.userID,
);
final tokens =
await pangeaMessageEvent.messageDisplayRepresentation?.tokensGlobal(
event.senderId,
event.originServerTs,
);
return MessageEventAndTokens(
event: pangeaMessageEvent,
tokens: tokens,
);
}
@override
Widget build(BuildContext context) {
if (event == null) return Text(L10n.of(context).emptyChat, style: style);
if (!_showPangeaContent(event!)) {
return FutureBuilder(
future: event!.calcLocalizedBody(
MatrixLocals(L10n.of(context)),
hideReply: true,
hideEdit: true,
plaintextBody: true,
removeMarkdown: true,
withSenderNamePrefix: !event.room.isDirectChat ||
event.room.directChatMatrixID != event.room.lastEvent?.senderId,
);
}
String? eventContextId = event.eventId;
if (!event.eventId.isValidMatrixId || event.eventId.sigil != '\$') {
eventContextId = null;
}
final Timeline timeline = event.room.timeline != null &&
event.room.timeline!.chunk.eventsMap.containsKey(eventContextId)
? event.room.timeline!
: await event.room.getTimeline(eventContextId: eventContextId);
final PangeaMessageEvent pangeaMessageEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: event.senderId == event.room.client.userID,
);
final l2Code = pangeaController.languageController.activeL2Code();
if (l2Code == null || l2Code == LanguageKeys.unknownLanguage) {
return event.body;
}
final String? text =
pangeaMessageEvent.messageDisplayRepresentation?.text;
final tokens =
await pangeaMessageEvent.messageDisplayRepresentation?.tokensGlobal(
event.senderId,
event.originServerTs,
);
if (tokens != null) {
final analyticsEntry = pangeaController.getAnalytics.perMessage.get(
tokens,
pangeaMessageEvent,
);
if (analyticsEntry?.nextActivity?.activityType ==
ActivityTypeEnum.hiddenWordListening) {
try {
return _constructTokens(
tokens,
analyticsEntry!.nextActivity!.tokens,
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {
"tokens": tokens,
"analyticsEntry": analyticsEntry,
},
);
}
}
}
final i18n = MatrixLocals(l10n);
if (text == null || event.room.lastEvent == null) {
return l10n.emptyChat;
}
if (!event.room.isDirectChat ||
event.room.directChatMatrixID != event.room.lastEvent!.senderId) {
final senderNameOrYou = event.senderId == event.room.client.userID
? i18n.you
: event.room
.getParticipants()
.firstWhereOrNull((u) => u.id != event.room.client.userID)
?.calcDisplayname(i18n: i18n) ??
event.room.lastEvent!.senderId;
return "$senderNameOrYou: $text";
}
return text;
} catch (e, s) {
// debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
data: {
"event": event.toJson(),
withSenderNamePrefix: !event!.room.isDirectChat ||
event!.room.directChatMatrixID != event!.room.lastEvent?.senderId,
),
builder: (context, snapshot) {
return Text(
snapshot.hasData && snapshot.data != null
? snapshot.data!
: L10n.of(context).emptyChat,
style: style,
);
},
);
return event.body;
}
return FutureBuilder(
future: _getPangeaMessageEvent(event!),
builder: (context, snapshot) {
if (snapshot.hasData) {
final messageEventAndTokens = snapshot.data as MessageEventAndTokens;
final pangeaMessageEvent = messageEventAndTokens.event;
final tokens = messageEventAndTokens.tokens;
final analyticsEntry = tokens != null
? MatrixState.pangeaController.getAnalytics.perMessage.get(
tokens,
pangeaMessageEvent,
)
: null;
return MessageTextWidget(
pangeaMessageEvent: pangeaMessageEvent,
style: style,
messageAnalyticsEntry: analyticsEntry,
isSelected: null,
onClick: null,
);
}
return Text(
L10n.of(context).emptyChat,
style: style,
);
},
);
}
}
class MessageEventAndTokens {
final PangeaMessageEvent event;
final List<PangeaToken>? tokens;
MessageEventAndTokens({
required this.event,
required this.tokens,
});
}

View file

@ -0,0 +1,63 @@
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/widgets/chat/message_token_text.dart';
import 'package:flutter/material.dart';
class MessageTextUtil {
static List<TokenPosition> getTokenPositions(
PangeaMessageEvent pangeaMessageEvent, {
MessageAnalyticsEntry? messageAnalyticsEntry,
bool Function(PangeaToken)? isSelected,
}) {
// Convert the entire message into a list of characters
final Characters messageCharacters =
pangeaMessageEvent.messageDisplayText.characters;
// When building token positions, use grapheme cluster indices
final List<TokenPosition> tokenPositions = [];
int globalIndex = 0;
for (final token
in pangeaMessageEvent.messageDisplayRepresentation!.tokens!) {
final start = token.start;
final end = token.end;
// Calculate the number of grapheme clusters up to the start and end positions
final int startIndex = messageCharacters.take(start).length;
final int endIndex = messageCharacters.take(end).length;
final hideContent =
messageAnalyticsEntry?.isTokenInHiddenWordActivity(token) ?? false;
final hasHiddenContent =
messageAnalyticsEntry?.hasHiddenWordActivity ?? false;
if (globalIndex < startIndex) {
tokenPositions.add(
TokenPosition(
start: globalIndex,
end: startIndex,
hideContent: false,
highlight: (isSelected?.call(token) ?? false) && !hasHiddenContent,
),
);
}
tokenPositions.add(
TokenPosition(
start: startIndex,
end: endIndex,
token: token,
hideContent: hideContent,
highlight: (isSelected?.call(token) ?? false) &&
!hideContent &&
!hasHiddenContent,
),
);
globalIndex = endIndex;
}
return tokenPositions;
}
}

View file

@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/utils/message_text_util.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@ -47,110 +48,18 @@ class MessageTokenText extends StatelessWidget {
);
}
// Convert the entire message into a list of characters
final Characters messageCharacters =
_pangeaMessageEvent.messageDisplayText.characters;
// When building token positions, use grapheme cluster indices
final List<TokenPosition> tokenPositions = [];
int globalIndex = 0;
for (final token
in _pangeaMessageEvent.messageDisplayRepresentation!.tokens!) {
final start = token.start;
final end = token.end;
// Calculate the number of grapheme clusters up to the start and end positions
final int startIndex = messageCharacters.take(start).length;
final int endIndex = messageCharacters.take(end).length;
final hideContent =
messageAnalyticsEntry?.isTokenInHiddenWordActivity(token) ?? false;
final hasHiddenContent =
messageAnalyticsEntry?.hasHiddenWordActivity ?? false;
if (globalIndex < startIndex) {
tokenPositions.add(
TokenPosition(
start: globalIndex,
end: startIndex,
hideContent: false,
highlight: (_isSelected?.call(token) ?? false) && !hasHiddenContent,
),
);
}
tokenPositions.add(
TokenPosition(
start: startIndex,
end: endIndex,
token: token,
hideContent: hideContent,
highlight: (_isSelected?.call(token) ?? false) &&
!hideContent &&
!hasHiddenContent,
),
);
globalIndex = endIndex;
}
void callOnClick(TokenPosition tokenPosition) {
_onClick != null && tokenPosition.token != null
? _onClick!(tokenPosition.token!)
: null;
}
return RichText(
text: TextSpan(
children:
tokenPositions.mapIndexed((int i, TokenPosition tokenPosition) {
final substring = messageCharacters
.skip(tokenPosition.start)
.take(tokenPosition.end - tokenPosition.start)
.toString();
if (tokenPosition.token != null) {
if (tokenPosition.hideContent) {
return WidgetSpan(
child: GestureDetector(
onTap: () => callOnClick(tokenPosition),
child: HiddenText(text: substring, style: _style),
),
);
}
return TextSpan(
recognizer: TapGestureRecognizer()
..onTap = () => callOnClick(tokenPosition),
text: substring,
style: _style.merge(
TextStyle(
backgroundColor: tokenPosition.highlight
? Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(0.4)
: Colors.white.withOpacity(0.4)
: Colors.transparent,
),
),
);
} else {
if ((i > 0 || i < tokenPositions.length - 1) &&
tokenPositions[i + 1].hideContent &&
tokenPositions[i - 1].hideContent) {
return WidgetSpan(
child: GestureDetector(
onTap: () => callOnClick(tokenPosition),
child: HiddenText(text: substring, style: _style),
),
);
}
return TextSpan(
text: substring,
style: _style,
);
}
}).toList(),
),
return MessageTextWidget(
pangeaMessageEvent: _pangeaMessageEvent,
style: _style,
messageAnalyticsEntry: messageAnalyticsEntry,
isSelected: _isSelected,
onClick: callOnClick,
);
}
}
@ -213,3 +122,89 @@ class HiddenText extends StatelessWidget {
);
}
}
class MessageTextWidget extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final TextStyle style;
final MessageAnalyticsEntry? messageAnalyticsEntry;
final bool Function(PangeaToken)? isSelected;
final void Function(TokenPosition tokenPosition)? onClick;
const MessageTextWidget({
super.key,
required this.pangeaMessageEvent,
required this.style,
this.messageAnalyticsEntry,
this.isSelected,
this.onClick,
});
@override
Widget build(BuildContext context) {
final Characters messageCharacters =
pangeaMessageEvent.messageDisplayText.characters;
final tokenPositions = MessageTextUtil.getTokenPositions(
pangeaMessageEvent,
messageAnalyticsEntry: messageAnalyticsEntry,
isSelected: isSelected,
);
return RichText(
text: TextSpan(
children:
tokenPositions.mapIndexed((int i, TokenPosition tokenPosition) {
final substring = messageCharacters
.skip(tokenPosition.start)
.take(tokenPosition.end - tokenPosition.start)
.toString();
if (tokenPosition.token != null) {
if (tokenPosition.hideContent) {
return WidgetSpan(
child: GestureDetector(
onTap: onClick != null
? () => onClick?.call(tokenPosition)
: null,
child: HiddenText(text: substring, style: style),
),
);
}
return TextSpan(
recognizer: TapGestureRecognizer()
..onTap =
onClick != null ? () => onClick?.call(tokenPosition) : null,
text: substring,
style: style.merge(
TextStyle(
backgroundColor: tokenPosition.highlight
? Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(0.4)
: Colors.white.withOpacity(0.4)
: Colors.transparent,
),
),
);
} else {
if ((i > 0 || i < tokenPositions.length - 1) &&
tokenPositions[i + 1].hideContent &&
tokenPositions[i - 1].hideContent) {
return WidgetSpan(
child: GestureDetector(
onTap: onClick != null
? () => onClick?.call(tokenPosition)
: null,
child: HiddenText(text: substring, style: style),
),
);
}
return TextSpan(
text: substring,
style: style,
);
}
}).toList(),
),
);
}
}