Merge branch 'verification_id' of github.com:ente-io/photos-app into verification_id
This commit is contained in:
commit
4f9b4fdc08
|
@ -25,7 +25,7 @@ import 'package:photos/ui/settings/social_section_widget.dart';
|
||||||
import 'package:photos/ui/settings/storage_card_widget.dart';
|
import 'package:photos/ui/settings/storage_card_widget.dart';
|
||||||
import 'package:photos/ui/settings/support_section_widget.dart';
|
import 'package:photos/ui/settings/support_section_widget.dart';
|
||||||
import 'package:photos/ui/settings/theme_switch_widget.dart';
|
import 'package:photos/ui/settings/theme_switch_widget.dart';
|
||||||
import "package:photos/ui/sharing/verify_identify_screen.dart";
|
import "package:photos/ui/sharing/verify_identity_dialog.dart";
|
||||||
import "package:photos/utils/navigation_util.dart";
|
import "package:photos/utils/navigation_util.dart";
|
||||||
|
|
||||||
class SettingsPage extends StatelessWidget {
|
class SettingsPage extends StatelessWidget {
|
||||||
|
@ -52,12 +52,12 @@ class SettingsPage extends StatelessWidget {
|
||||||
final enteTextTheme = getEnteTextTheme(context);
|
final enteTextTheme = getEnteTextTheme(context);
|
||||||
final List<Widget> contents = [];
|
final List<Widget> contents = [];
|
||||||
contents.add(
|
contents.add(
|
||||||
InkWell(
|
GestureDetector(
|
||||||
onDoubleTap: () {
|
onDoubleTap: () {
|
||||||
routeToPage(context, VerifyIdentifyScreen(self: true));
|
_showVerifyIdentityDialog(context);
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
routeToPage(context, VerifyIdentifyScreen(self: true));
|
_showVerifyIdentityDialog(context);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 350),
|
constraints: const BoxConstraints(maxWidth: 350),
|
||||||
|
@ -165,4 +165,13 @@ class SettingsPage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showVerifyIdentityDialog(BuildContext context) async {
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return VerifyIdentifyDialog(self: true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,9 +13,8 @@ import 'package:photos/ui/components/menu_section_description_widget.dart';
|
||||||
import 'package:photos/ui/components/menu_section_title.dart';
|
import 'package:photos/ui/components/menu_section_title.dart';
|
||||||
import 'package:photos/ui/components/models/button_type.dart';
|
import 'package:photos/ui/components/models/button_type.dart';
|
||||||
import 'package:photos/ui/sharing/user_avator_widget.dart';
|
import 'package:photos/ui/sharing/user_avator_widget.dart';
|
||||||
import "package:photos/ui/sharing/verify_identify_screen.dart";
|
import "package:photos/ui/sharing/verify_identity_dialog.dart";
|
||||||
import "package:photos/utils/dialog_util.dart";
|
import "package:photos/utils/dialog_util.dart";
|
||||||
import "package:photos/utils/navigation_util.dart";
|
|
||||||
|
|
||||||
class AddParticipantPage extends StatefulWidget {
|
class AddParticipantPage extends StatefulWidget {
|
||||||
final Collection collection;
|
final Collection collection;
|
||||||
|
@ -212,9 +211,14 @@ class _AddParticipantPage extends State<AddParticipantPage> {
|
||||||
}
|
}
|
||||||
final emailToAdd =
|
final emailToAdd =
|
||||||
selectedEmail == '' ? _email : selectedEmail;
|
selectedEmail == '' ? _email : selectedEmail;
|
||||||
routeToPage(
|
showDialog(
|
||||||
context,
|
context: context,
|
||||||
VerifyIdentifyScreen(self: false, email: emailToAdd),
|
builder: (BuildContext context) {
|
||||||
|
return VerifyIdentifyDialog(
|
||||||
|
self: false,
|
||||||
|
email: emailToAdd,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
|
@ -1,248 +0,0 @@
|
||||||
import "dart:convert";
|
|
||||||
|
|
||||||
import 'package:bip39/bip39.dart' as bip39;
|
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import "package:dotted_border/dotted_border.dart";
|
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:flutter/services.dart";
|
|
||||||
import "package:logging/logging.dart";
|
|
||||||
import "package:photos/core/configuration.dart";
|
|
||||||
import "package:photos/services/user_service.dart";
|
|
||||||
import "package:photos/theme/ente_theme.dart";
|
|
||||||
import "package:photos/ui/common/loading_widget.dart";
|
|
||||||
import "package:photos/ui/components/button_widget.dart";
|
|
||||||
import "package:photos/ui/components/icon_button_widget.dart";
|
|
||||||
import "package:photos/ui/components/models/button_type.dart";
|
|
||||||
import "package:photos/ui/components/title_bar_title_widget.dart";
|
|
||||||
import "package:photos/ui/components/title_bar_widget.dart";
|
|
||||||
import "package:photos/utils/share_util.dart";
|
|
||||||
|
|
||||||
class VerifyIdentifyScreen extends StatefulWidget {
|
|
||||||
// email id of the user who's verification ID is being displayed for
|
|
||||||
// verification
|
|
||||||
final String email;
|
|
||||||
|
|
||||||
// self is true when the user is viewing their own verification ID
|
|
||||||
final bool self;
|
|
||||||
|
|
||||||
VerifyIdentifyScreen({
|
|
||||||
Key? key,
|
|
||||||
required this.self,
|
|
||||||
this.email = '',
|
|
||||||
}) : super(key: key) {
|
|
||||||
if (!self && email.isEmpty) {
|
|
||||||
throw ArgumentError("email cannot be empty when self is false");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<VerifyIdentifyScreen> createState() => _VerifyIdentifyScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VerifyIdentifyScreenState extends State<VerifyIdentifyScreen> {
|
|
||||||
final _logger = Logger("VerifyIdentifyScreen");
|
|
||||||
final bool doesUserExist = true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final textStyle = getEnteTextTheme(context);
|
|
||||||
final String subTitle = widget.self
|
|
||||||
? "This is your Verification ID"
|
|
||||||
: "This is ${widget.email}'s Verification ID";
|
|
||||||
final String bottomText = widget.self
|
|
||||||
? "Someone sharing albums with you should see the same ID on their "
|
|
||||||
"device."
|
|
||||||
: "Please ask them to long-press their email address on the settings "
|
|
||||||
"screen, and verify that the IDs on both devices match.";
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
body: CustomScrollView(
|
|
||||||
primary: false,
|
|
||||||
slivers: <Widget>[
|
|
||||||
TitleBarWidget(
|
|
||||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
|
||||||
title: widget.self ? "Verification ID" : "Verify ${widget.email}",
|
|
||||||
),
|
|
||||||
actionIcons: [
|
|
||||||
IconButtonWidget(
|
|
||||||
icon: Icons.close_outlined,
|
|
||||||
iconButtonType: IconButtonType.secondary,
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
|
||||||
(delegateBuildContext, index) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
FutureBuilder<String>(
|
|
||||||
future: _getPublicKey(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.hasData) {
|
|
||||||
final publicKey = snapshot.data!;
|
|
||||||
if (publicKey.isEmpty) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
"${widget.email} does not have an ente "
|
|
||||||
"account\n"
|
|
||||||
"\nSend them an invite to add them after they sign up",
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
ButtonWidget(
|
|
||||||
buttonType: ButtonType.neutral,
|
|
||||||
icon: Icons.adaptive.share,
|
|
||||||
labelText: "Send invite",
|
|
||||||
isInAlert: true,
|
|
||||||
onTap: () async {
|
|
||||||
shareText(
|
|
||||||
"Download ente so we can easily share original quality photos"
|
|
||||||
" and videos\n\nhttps://ente.io/#download",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
subTitle,
|
|
||||||
style: textStyle.bodyMuted,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_verificationIDWidget(context, publicKey),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(bottomText, style: textStyle.body),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
Logger("VerificationID")
|
|
||||||
.severe("failed to end userID", snapshot.error);
|
|
||||||
return Text(
|
|
||||||
"Something went wrong",
|
|
||||||
style: textStyle.bodyMuted,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return const SizedBox(
|
|
||||||
height: 200,
|
|
||||||
child: EnteLoadingWidget(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
childCount: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SliverFillRemaining(
|
|
||||||
hasScrollBody: false,
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.bottomCenter,
|
|
||||||
child: Padding(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(vertical: 32, horizontal: 20),
|
|
||||||
child: ButtonWidget(
|
|
||||||
buttonType: ButtonType.neutral,
|
|
||||||
isInAlert: true,
|
|
||||||
labelText: widget.self ? "OK" : "Done",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPublicKey will return empty string if the user is not found for given
|
|
||||||
Future<String> _getPublicKey() async {
|
|
||||||
if (widget.self) {
|
|
||||||
return Configuration.instance.getKeyAttributes()!.publicKey;
|
|
||||||
}
|
|
||||||
final String? userPublicKey =
|
|
||||||
await UserService.instance.getPublicKey(widget.email);
|
|
||||||
if (userPublicKey == null) {
|
|
||||||
// user not found
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return userPublicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _verificationIDWidget(BuildContext context, String publicKey) {
|
|
||||||
final colorScheme = getEnteColorScheme(context);
|
|
||||||
final textStyle = getEnteTextTheme(context);
|
|
||||||
final String verificationID = _generateVerificationID(publicKey);
|
|
||||||
return DottedBorder(
|
|
||||||
color: colorScheme.strokeMuted,
|
|
||||||
//color of dotted/dash line
|
|
||||||
strokeWidth: 1,
|
|
||||||
|
|
||||||
dashPattern: const [12, 6],
|
|
||||||
radius: const Radius.circular(8),
|
|
||||||
//dash patterns, 10 is dash width, 6 is space width
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () async {
|
|
||||||
if (verificationID.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Clipboard.setData(
|
|
||||||
ClipboardData(text: verificationID),
|
|
||||||
);
|
|
||||||
shareText(
|
|
||||||
widget.self
|
|
||||||
? "Here's my verification ID: "
|
|
||||||
"$verificationID for ente.io."
|
|
||||||
: "Hey, "
|
|
||||||
"can you confirm that "
|
|
||||||
"this is your ente.io verification "
|
|
||||||
"ID: $verificationID",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: const BorderRadius.all(
|
|
||||||
Radius.circular(2),
|
|
||||||
),
|
|
||||||
color: colorScheme.backgroundElevated2,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
width: double.infinity,
|
|
||||||
child: Text(
|
|
||||||
verificationID,
|
|
||||||
style: textStyle.bodyBold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _generateVerificationID(String publicKey) {
|
|
||||||
final inputBytes = base64.decode(publicKey);
|
|
||||||
final shaValue = sha256.convert(inputBytes);
|
|
||||||
return bip39.generateMnemonic(
|
|
||||||
strength: 256,
|
|
||||||
randomBytes: (int size) {
|
|
||||||
return Uint8List.fromList(shaValue.bytes);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
209
lib/ui/sharing/verify_identity_dialog.dart
Normal file
209
lib/ui/sharing/verify_identity_dialog.dart
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
import "dart:convert";
|
||||||
|
|
||||||
|
import 'package:bip39/bip39.dart' as bip39;
|
||||||
|
import "package:crypto/crypto.dart";
|
||||||
|
import "package:dotted_border/dotted_border.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter/services.dart";
|
||||||
|
import "package:logging/logging.dart";
|
||||||
|
import "package:photos/core/configuration.dart";
|
||||||
|
import "package:photos/services/user_service.dart";
|
||||||
|
import "package:photos/theme/ente_theme.dart";
|
||||||
|
import "package:photos/ui/common/loading_widget.dart";
|
||||||
|
import "package:photos/ui/components/button_widget.dart";
|
||||||
|
import "package:photos/ui/components/models/button_type.dart";
|
||||||
|
import "package:photos/utils/share_util.dart";
|
||||||
|
|
||||||
|
class VerifyIdentifyDialog extends StatefulWidget {
|
||||||
|
// email id of the user who's verification ID is being displayed for
|
||||||
|
// verification
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
// self is true when the user is viewing their own verification ID
|
||||||
|
final bool self;
|
||||||
|
|
||||||
|
VerifyIdentifyDialog({
|
||||||
|
Key? key,
|
||||||
|
required this.self,
|
||||||
|
this.email = '',
|
||||||
|
}) : super(key: key) {
|
||||||
|
if (!self && email.isEmpty) {
|
||||||
|
throw ArgumentError("email cannot be empty when self is false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VerifyIdentifyDialog> createState() => _VerifyIdentifyDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VerifyIdentifyDialogState extends State<VerifyIdentifyDialog> {
|
||||||
|
final bool doesUserExist = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final textStyle = getEnteTextTheme(context);
|
||||||
|
final String subTitle = widget.self
|
||||||
|
? "This is your Verification ID"
|
||||||
|
: "This is ${widget.email}'s Verification ID";
|
||||||
|
final String bottomText = widget.self
|
||||||
|
? "Someone sharing albums with you should see the same ID on their "
|
||||||
|
"device."
|
||||||
|
: "Please ask them to long-press their email address on the settings "
|
||||||
|
"screen, and verify that the IDs on both devices match.";
|
||||||
|
|
||||||
|
final AlertDialog alert = AlertDialog(
|
||||||
|
title: Text(widget.self ? "Verification ID" : "Verify ${widget.email}"),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future: _getPublicKey(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
final publicKey = snapshot.data!;
|
||||||
|
if (publicKey.isEmpty) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"${widget.email} does not have an ente "
|
||||||
|
"account.\n"
|
||||||
|
"\nSend them an invite to share photos.",
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ButtonWidget(
|
||||||
|
buttonType: ButtonType.neutral,
|
||||||
|
icon: Icons.adaptive.share,
|
||||||
|
labelText: "Send invite",
|
||||||
|
isInAlert: true,
|
||||||
|
onTap: () async {
|
||||||
|
shareText(
|
||||||
|
"Download ente so we can easily share original quality photos"
|
||||||
|
" and videos\n\nhttps://ente.io/",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
subTitle,
|
||||||
|
style: textStyle.bodyMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_verificationIDWidget(context, publicKey),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
bottomText,
|
||||||
|
style: textStyle.bodyMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ButtonWidget(
|
||||||
|
buttonType: ButtonType.neutral,
|
||||||
|
isInAlert: true,
|
||||||
|
labelText: widget.self ? "OK" : "Done",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
Logger("VerificationID")
|
||||||
|
.severe("failed to end userID", snapshot.error);
|
||||||
|
return Text(
|
||||||
|
"Something went wrong",
|
||||||
|
style: textStyle.bodyMuted,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: EnteLoadingWidget(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _getPublicKey() async {
|
||||||
|
if (widget.self) {
|
||||||
|
return Configuration.instance.getKeyAttributes()!.publicKey;
|
||||||
|
}
|
||||||
|
final String? userPublicKey =
|
||||||
|
await UserService.instance.getPublicKey(widget.email);
|
||||||
|
if (userPublicKey == null) {
|
||||||
|
// user not found
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return userPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _verificationIDWidget(BuildContext context, String publicKey) {
|
||||||
|
final colorScheme = getEnteColorScheme(context);
|
||||||
|
final textStyle = getEnteTextTheme(context);
|
||||||
|
final String verificationID = _generateVerificationID(publicKey);
|
||||||
|
return DottedBorder(
|
||||||
|
color: colorScheme.strokeMuted,
|
||||||
|
//color of dotted/dash line
|
||||||
|
strokeWidth: 1,
|
||||||
|
|
||||||
|
dashPattern: const [12, 6],
|
||||||
|
radius: const Radius.circular(8),
|
||||||
|
//dash patterns, 10 is dash width, 6 is space width
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
if (verificationID.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(text: verificationID),
|
||||||
|
);
|
||||||
|
shareText(
|
||||||
|
widget.self
|
||||||
|
? "Here's my verification ID: "
|
||||||
|
"$verificationID for ente.io."
|
||||||
|
: "Hey, "
|
||||||
|
"can you confirm that "
|
||||||
|
"this is your ente.io verification "
|
||||||
|
"ID: $verificationID",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(2),
|
||||||
|
),
|
||||||
|
color: colorScheme.backgroundElevated2,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
width: double.infinity,
|
||||||
|
child: Text(
|
||||||
|
verificationID,
|
||||||
|
style: textStyle.bodyBold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generateVerificationID(String publicKey) {
|
||||||
|
final inputBytes = base64.decode(publicKey);
|
||||||
|
final shaValue = sha256.convert(inputBytes);
|
||||||
|
return bip39.generateMnemonic(
|
||||||
|
strength: 256,
|
||||||
|
randomBytes: (int size) {
|
||||||
|
return Uint8List.fromList(shaValue.bytes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue