experimenting
This commit is contained in:
parent
5901de1ab0
commit
57957eac9e
10 changed files with 346 additions and 149 deletions
|
|
@ -1,12 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pangea/config/environment.dart';
|
||||
import 'package:fluffychat/pangea/controllers/language_list_controller.dart';
|
||||
|
|
@ -15,6 +7,13 @@ import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
|
|||
import 'package:fluffychat/utils/client_manager.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'package:fluffychat/widgets/error_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'config/setting_keys.dart';
|
||||
import 'utils/background_push.dart';
|
||||
import 'widgets/fluffy_chat_app.dart';
|
||||
|
|
|
|||
|
|
@ -1277,10 +1277,11 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
}
|
||||
|
||||
void onSelectMessage(Event event) {
|
||||
// #Pangea
|
||||
// #Pangea
|
||||
if (choreographer.itController.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pangea#
|
||||
if (!event.redacted) {
|
||||
if (selectedEvents.contains(event)) {
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ class ChatEventList extends StatelessWidget {
|
|||
scrollToEventId: (String eventId) =>
|
||||
controller.scrollToEventId(eventId),
|
||||
// #Pangea
|
||||
// longPressSelect: controller.selectedEvents.isEmpty,
|
||||
longPressSelect: controller.selectedEvents.isNotEmpty,
|
||||
selectedDisplayLang:
|
||||
controller.choreographer.messageOptions.selectedDisplayLang,
|
||||
immersionMode: controller.choreographer.immersionMode,
|
||||
|
|
|
|||
|
|
@ -241,8 +241,14 @@ class Message extends StatelessWidget {
|
|||
alignment: alignment,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => print("got message tap"),
|
||||
onDoubleTap: () => print("got message double tap"),
|
||||
onDoubleTapDown: (details) =>
|
||||
print("got message double tap down"),
|
||||
onLongPress: longPressSelect
|
||||
? null
|
||||
? selected
|
||||
? null
|
||||
: () => print('long press')
|
||||
: () {
|
||||
onSelect(event);
|
||||
// Android usually has a vibration effect on long press:
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ class MessageContent extends StatelessWidget {
|
|||
cause: cause,
|
||||
context: context,
|
||||
),
|
||||
onTap: () => messageToolbar?.onTextTap(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/controllers/base_controller.dart';
|
||||
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
|
||||
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
|
||||
import 'package:fluffychat/pangea/models/message_data_models.dart';
|
||||
import 'package:fluffychat/pangea/repo/tokens_repo.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../constants/pangea_event_types.dart';
|
||||
import '../enum/use_type.dart';
|
||||
import '../models/choreo_record.dart';
|
||||
|
|
|
|||
|
|
@ -591,51 +591,51 @@ extension PangeaRoom on Room {
|
|||
required String parentEventId,
|
||||
required String type,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint("creating $type child for $parentEventId");
|
||||
Sentry.addBreadcrumb(Breadcrumb.fromJson(content));
|
||||
if (parentEventId.contains("web")) {
|
||||
debugger(when: kDebugMode);
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb(
|
||||
message:
|
||||
"sendPangeaEvent with likely invalid parentEventId $parentEventId",
|
||||
),
|
||||
);
|
||||
}
|
||||
final Map<String, dynamic> repContent = {
|
||||
// what is the functionality of m.reference?
|
||||
"m.relates_to": {"rel_type": type, "event_id": parentEventId},
|
||||
type: content,
|
||||
};
|
||||
|
||||
final String? newEventId = await sendEvent(repContent, type: type);
|
||||
|
||||
if (newEventId == null) {
|
||||
debugger(when: kDebugMode);
|
||||
}
|
||||
|
||||
//PTODO - handle the frequent case of a null newEventId
|
||||
final Event? newEvent = await getEventById(newEventId!);
|
||||
|
||||
if (newEvent == null) {
|
||||
debugger(when: kDebugMode);
|
||||
}
|
||||
|
||||
return newEvent;
|
||||
} catch (err, stack) {
|
||||
// try {
|
||||
debugPrint("creating $type child for $parentEventId");
|
||||
Sentry.addBreadcrumb(Breadcrumb.fromJson(content));
|
||||
if (parentEventId.contains("web")) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
e: err,
|
||||
s: stack,
|
||||
data: {
|
||||
"type": type,
|
||||
"parentEventId": parentEventId,
|
||||
"content": content,
|
||||
},
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb(
|
||||
message:
|
||||
"sendPangeaEvent with likely invalid parentEventId $parentEventId",
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final Map<String, dynamic> repContent = {
|
||||
// what is the functionality of m.reference?
|
||||
"m.relates_to": {"rel_type": type, "event_id": parentEventId},
|
||||
type: content,
|
||||
};
|
||||
|
||||
final String? newEventId = await sendEvent(repContent, type: type);
|
||||
|
||||
if (newEventId == null) {
|
||||
debugger(when: kDebugMode);
|
||||
}
|
||||
|
||||
//PTODO - handle the frequent case of a null newEventId
|
||||
final Event? newEvent = await getEventById(newEventId!);
|
||||
|
||||
if (newEvent == null) {
|
||||
debugger(when: kDebugMode);
|
||||
}
|
||||
|
||||
return newEvent;
|
||||
// } catch (err, stack) {
|
||||
// debugger(when: kDebugMode);
|
||||
// ErrorHandler.logError(
|
||||
// e: err,
|
||||
// s: stack,
|
||||
// data: {
|
||||
// "type": type,
|
||||
// "parentEventId": parentEventId,
|
||||
// "content": content,
|
||||
// },
|
||||
// );
|
||||
// return null;
|
||||
// }
|
||||
}
|
||||
|
||||
ConstructEvent? _vocabEventLocal(String lemma) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../models/widget_measurement.dart';
|
||||
|
||||
class PangeaAnyState {
|
||||
final Map<String, StreamController<WidgetMeasurements>?> _streams = {};
|
||||
final Map<String, List<WidgetMeasurements>> _pastValues = {};
|
||||
final Map<String, LayerLinkAndKey> _layerLinkAndKeys = {};
|
||||
OverlayEntry? overlay;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,94 +1,138 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:fluffychat/pangea/utils/any_state_holder.dart';
|
||||
import 'package:fluffychat/pangea/utils/overlay.dart';
|
||||
import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
enum MessageMode { translation, play, definition, image, spellCheck }
|
||||
|
||||
class MessageOverlay {
|
||||
static void showOverlay(BuildContext context, GlobalKey targetKey) {
|
||||
final RenderBox renderBox =
|
||||
targetKey.currentContext?.findRenderObject() as RenderBox;
|
||||
final Offset offset = renderBox.localToGlobal(Offset.zero);
|
||||
final Size size = renderBox.size;
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
class MessageOverlayController {
|
||||
OverlayEntry? _overlayEntry;
|
||||
final BuildContext _context;
|
||||
final GlobalKey _targetKey;
|
||||
MessageMode? _currentMode;
|
||||
AnimationController? _animationController;
|
||||
|
||||
// Determines the vertical position of the overlay
|
||||
final bool isBottomRoomAvailable =
|
||||
MediaQuery.of(context).size.height - (offset.dy + size.height) >=
|
||||
size.height;
|
||||
|
||||
OverlayEntry overlayEntry;
|
||||
MessageMode currentMode = MessageMode.translation;
|
||||
|
||||
// Function to build the content based on the selected mode
|
||||
Widget buildContent() {
|
||||
switch (currentMode) {
|
||||
case MessageMode.translation:
|
||||
return const Text('Translation Mode');
|
||||
case MessageMode.play:
|
||||
return const Text('Play Mode');
|
||||
case MessageMode.definition:
|
||||
return const Text('Definition Mode');
|
||||
case MessageMode.image:
|
||||
return const Text('Image Mode');
|
||||
case MessageMode.spellCheck:
|
||||
return const Text('SpellCheck Mode');
|
||||
default:
|
||||
return const SizedBox.shrink(); // Returns an empty container
|
||||
}
|
||||
}
|
||||
|
||||
// Function to show the overlay with an animation
|
||||
overlayEntry = OverlayEntry(
|
||||
builder: (context) => AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
left: offset.dx + size.width / 2 - screenWidth / 2,
|
||||
right: screenWidth - (offset.dx + size.width / 2 + screenWidth / 2),
|
||||
top: isBottomRoomAvailable ? offset.dy + size.height : null,
|
||||
bottom: isBottomRoomAvailable
|
||||
? null
|
||||
: MediaQuery.of(context).size.height - offset.dy,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
width: screenWidth,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
children: MessageMode.values.map((mode) {
|
||||
return IconButton(
|
||||
icon: Icon(_getIconData(mode)),
|
||||
onPressed: () {
|
||||
currentMode = mode;
|
||||
overlayEntry.markNeedsBuild();
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
SizeTransition(
|
||||
sizeFactor: currentMode != null
|
||||
? CurvedAnimation(
|
||||
parent: Overlay.of(context).animation!,
|
||||
curve: Curves.fastOutSlowIn)
|
||||
: const AlwaysStoppedAnimation(0),
|
||||
axisAlignment: -1.0,
|
||||
child: buildContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
MessageOverlayController(this._context, this._targetKey) {
|
||||
_animationController = AnimationController(
|
||||
vsync: Navigator.of(_context), // Using the Navigator's TickerProvider
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(overlayEntry);
|
||||
}
|
||||
|
||||
static IconData _getIconData(MessageMode mode) {
|
||||
void showOverlay() {
|
||||
final RenderBox renderBox =
|
||||
_targetKey.currentContext?.findRenderObject() as RenderBox;
|
||||
final Offset offset = renderBox.localToGlobal(Offset.zero);
|
||||
final Size size = renderBox.size;
|
||||
final double screenWidth = MediaQuery.of(_context).size.width;
|
||||
|
||||
// Determines if there is more room above or below the RenderBox
|
||||
final bool isBottomRoomAvailable =
|
||||
MediaQuery.of(_context).size.height - (offset.dy + size.height) >=
|
||||
size.height;
|
||||
final double topPosition = isBottomRoomAvailable
|
||||
? offset.dy + size.height
|
||||
: offset.dy - size.height;
|
||||
|
||||
// Ensure the overlay does not overflow the screen horizontally
|
||||
double leftPosition = offset.dx + size.width / 2 - screenWidth / 2;
|
||||
leftPosition = leftPosition < 0 ? 0 : leftPosition;
|
||||
final double rightPosition =
|
||||
leftPosition + screenWidth > MediaQuery.of(_context).size.width
|
||||
? MediaQuery.of(_context).size.width - leftPosition - screenWidth
|
||||
: leftPosition;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter setState) {
|
||||
return AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
left: leftPosition,
|
||||
right: rightPosition,
|
||||
top: isBottomRoomAvailable ? topPosition : null,
|
||||
bottom: isBottomRoomAvailable
|
||||
? null
|
||||
: MediaQuery.of(_context).size.height -
|
||||
topPosition -
|
||||
size.height,
|
||||
child: AnimatedSize(
|
||||
curve: Curves.easeInOut,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: MessageMode.values.map((mode) {
|
||||
return IconButton(
|
||||
icon: Icon(_getIconData(mode)),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_currentMode = mode;
|
||||
});
|
||||
_animationController?.forward();
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
SizeTransition(
|
||||
sizeFactor: CurvedAnimation(
|
||||
parent: _animationController!,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
),
|
||||
axisAlignment: -1.0,
|
||||
child: _buildModeContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Overlay.of(_context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
void hideOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
_animationController?.reverse();
|
||||
}
|
||||
|
||||
Widget _buildModeContent() {
|
||||
switch (_currentMode) {
|
||||
case MessageMode.translation:
|
||||
return const Text('Translation Mode');
|
||||
case MessageMode.play:
|
||||
return const Text('Play Mode');
|
||||
case MessageMode.definition:
|
||||
return const Text('Definition Mode');
|
||||
case MessageMode.image:
|
||||
return const Text('Image Mode');
|
||||
case MessageMode.spellCheck:
|
||||
return const Text('SpellCheck Mode');
|
||||
default:
|
||||
return const SizedBox
|
||||
.shrink(); // Empty container for the default case, meaning no content
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getIconData(MessageMode mode) {
|
||||
switch (mode) {
|
||||
case MessageMode.translation:
|
||||
return Icons.g_translate;
|
||||
|
|
@ -101,7 +145,159 @@ class MessageOverlay {
|
|||
case MessageMode.spellCheck:
|
||||
return Icons.spellcheck;
|
||||
default:
|
||||
return Icons.error;
|
||||
return Icons.error; // Icon to indicate an error or unsupported mode
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_overlayEntry?.dispose();
|
||||
_animationController?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ShowDefintionUtil {
|
||||
String messageText;
|
||||
final String langCode;
|
||||
final String targetId;
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final Room room;
|
||||
String? textSelection;
|
||||
bool inCooldown = false;
|
||||
double? dx;
|
||||
double? dy;
|
||||
|
||||
ShowDefintionUtil({
|
||||
required this.targetId,
|
||||
required this.room,
|
||||
required this.langCode,
|
||||
required this.messageText,
|
||||
});
|
||||
|
||||
void onTextSelection({
|
||||
required BuildContext context,
|
||||
TextSelection? selectedText,
|
||||
SelectedContent? selectedContent,
|
||||
SelectionChangedCause? cause,
|
||||
}) {
|
||||
if ((selectedText == null && selectedContent == null) ||
|
||||
selectedText?.isCollapsed == true) {
|
||||
clearTextSelection();
|
||||
return;
|
||||
}
|
||||
textSelection = selectedText != null
|
||||
? selectedText.textInside(messageText)
|
||||
: selectedContent!.plainText;
|
||||
|
||||
if (BrowserContextMenu.enabled && kIsWeb) {
|
||||
BrowserContextMenu.disableContextMenu();
|
||||
}
|
||||
|
||||
if (kIsWeb && cause != SelectionChangedCause.tap) {
|
||||
handleToolbar(context);
|
||||
}
|
||||
}
|
||||
|
||||
void clearTextSelection() {
|
||||
textSelection = null;
|
||||
if (kIsWeb && !BrowserContextMenu.enabled) {
|
||||
BrowserContextMenu.enableContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
void handleToolbar(BuildContext context) async {
|
||||
if (inCooldown || OverlayUtil.isOverlayOpen || !kIsWeb) return;
|
||||
inCooldown = true;
|
||||
Timer(const Duration(milliseconds: 750), () => inCooldown = false);
|
||||
await Future.delayed(const Duration(milliseconds: 750));
|
||||
showToolbar(context);
|
||||
}
|
||||
|
||||
void showDefinition(BuildContext context) {
|
||||
if (textSelection == null) return;
|
||||
OverlayUtil.showPositionedCard(
|
||||
context: context,
|
||||
cardToShow: WordDataCard(
|
||||
word: textSelection!,
|
||||
wordLang: langCode,
|
||||
fullText: messageText,
|
||||
fullTextLang: langCode,
|
||||
hasInfo: false,
|
||||
room: room,
|
||||
),
|
||||
cardSize: const Size(300, 300),
|
||||
transformTargetId: targetId,
|
||||
backDropToDismiss: false,
|
||||
);
|
||||
}
|
||||
|
||||
// web toolbar
|
||||
Future<dynamic> showToolbar(BuildContext context) async {
|
||||
final LayerLinkAndKey layerLinkAndKey =
|
||||
MatrixState.pAnyState.layerLinkAndKey(targetId);
|
||||
|
||||
final RenderObject? targetRenderBox =
|
||||
layerLinkAndKey.key.currentContext!.findRenderObject();
|
||||
final Offset transformTargetOffset =
|
||||
(targetRenderBox as RenderBox).localToGlobal(Offset.zero);
|
||||
|
||||
if (dx != null && dx! > MediaQuery.of(context).size.width - 130) {
|
||||
dx = MediaQuery.of(context).size.width - 130;
|
||||
}
|
||||
final double xOffset = dx != null ? dx! - transformTargetOffset.dx : 0;
|
||||
final double yOffset =
|
||||
dy != null ? dy! - transformTargetOffset.dy + 10 : 10;
|
||||
|
||||
OverlayUtil.showOverlay(
|
||||
context: context,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: Size.zero,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: () {
|
||||
showDefinition(context);
|
||||
},
|
||||
child: Text(
|
||||
L10n.of(context)!.showDefinition,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
size: const Size(130, 45),
|
||||
transformTargetId: targetId,
|
||||
offset: Offset(xOffset, yOffset),
|
||||
);
|
||||
}
|
||||
|
||||
void onMouseRegionUpdate(PointerEvent event) {
|
||||
dx = event.position.dx;
|
||||
dy = event.position.dy;
|
||||
}
|
||||
|
||||
Widget contextMenuOverride({
|
||||
required BuildContext context,
|
||||
EditableTextState? textSelection,
|
||||
SelectableRegionState? contentSelection,
|
||||
}) {
|
||||
if (textSelection == null && contentSelection == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: textSelection?.contextMenuAnchors ??
|
||||
contentSelection!.contextMenuAnchors,
|
||||
buttonItems: [
|
||||
if (textSelection != null) ...textSelection.contextMenuButtonItems,
|
||||
if (contentSelection != null)
|
||||
...contentSelection.contextMenuButtonItems,
|
||||
ContextMenuButtonItem(
|
||||
label: L10n.of(context)!.showDefinition,
|
||||
onPressed: () {
|
||||
showDefinition(context);
|
||||
focusNode.unfocus();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ class PangeaRichTextState extends State<PangeaRichText> {
|
|||
cause: cause,
|
||||
context: context,
|
||||
),
|
||||
onTap: () => messageToolbar?.onTextTap(context),
|
||||
focusNode: widget.messageToolbar?.focusNode,
|
||||
contextMenuBuilder: (context, state) =>
|
||||
widget.messageToolbar?.contextMenuOverride(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue