Nostr Tools Flutter Tutorial: Implementing Key Generation and Event Publishing [Part 2]
Welcome to the Nostr Tools Flutter Tutorial!
In Part 1 of this tutorial series, you learned how to implement a simple feed in Nostr using the Nostr Tools Flutter package.
In Part 2 of the series, we will cover the following topics:
-
Generating keys
-
Storing keys securely using flutter_secure_storage
-
Encoding and decoding keys as described in the Nip-19 proposal
-
Obtaining private keys as input from the user and validating them
-
Implementing the pull-to-refresh feature for feeds
-
Publishing events to the relays
But before we dive into the details, it's important to note that this tutorial is part two of a three-part series. If you haven't gone through Part 1 yet, I encourage you to take a look at it before continuing with Part 2. Also, make sure to familiarize yourself with the theory of Nip-19, as we'll be using it to encode and decode the keys.
Now, let's get started!
Setting up the Project
To begin with Part 2 development, we need to clone the starter project specifically created for this tutorial. This project includes pre-built UI widgets that we'll use to build our app.
Note: It's recommended to start with the starter project for Part 2, as it contains some changes specific to this article.
Here's how you can clone the starter project for Part 2. Open your terminal and run the following commands:
$ git clone https://github.com/aniketambore/ntf-materials.git
$ cd ntf-materials/02-noost-client/projects/starter
$ code .
This will open the Part 2 starter project in VS Code. Alternatively, you can use your preferred IDE to open the starter project. Once it's open, run flutter pub get
if necessary, and then run the app.
When you run the app, you'll see a simple Nostr feed screen, which is what we implemented in Part 1 of this tutorial series.
Take a moment to review what has changed in the starter project since Part 1 of the tutorial.
Project Files
Component Library
We have added several new reusable component widgets in the Component Library, including:
-
NoostCurveButton
-
NoostOkButton
-
NoostSnackBar
-
NoostTextButton
-
NoostTextFormField
We will see how to use these components across different screens later in the tutorial.
Screens Folder
If you take a look at the screens folder, you will notice that we don't have the noost_feed_screen.dart
file directly inside it, as we did in Part 1 of the tutorial. Instead, we have a feed_screen
folder inside the screens folder, and the noost_feed_screen.dart
file is located inside that folder. We also have a widget folder inside the feed_screen
folder, which contains widgets that are specific to the feed screen.
Noost Feed Screen
If you open the noost_feed_screen.dart
file, you will see that in the _NoostFeedScreenState
class, there are several TODOs. These are the functionalities that we will be implementing in this tutorial. Take a look at the _connectToRelay
and _initStream
methods as well. In Part 1 of the tutorial, we implemented the relayStream
method, but here we are breaking that logic into two separate methods to easily implement the resubscribing functionality in our app, which we will see further in this tutorial.
Secure Storage
The flutter_secure_storage package is a recommended way to store sensitive information such as private and public keys, as it leverages the most secure storage options provided by each platform, such as Apple's Keychain on iOS and Google's Keystore on Android.
To use flutter_secure_storage
in your project:
Open the pubspec.yaml
file and add the following package under dependencies, beneath nostr_tools
:
flutter_secure_storage: ^8.0.0
Save the file and run flutter pub get
to install the package.
Modify the minSdkVersion
in the android/app/build.gradle
file. The flutter_secure_storage
plugin requires Android 4.3 (API level 18) or higher. So update the minSdkVersion
inside the defaultConfig
block as follows:
minSdkVersion 18
Now you are ready to work with secure storage in your project!
Initializing Defaults
In order to securely store and manage the private and public keys, as well as handle user input and form validation, we need to initialize some default state variables in the _NoostFeedScreenState
class.
First, let's initialize an instance of FlutterSecureStorage
to securely store our keys. We can do this by replacing // TODO: Initialize an instance of FlutterSecureStorage
with the following code:
final _secureStorage = const FlutterSecureStorage();
Next, we need to initialize two empty String
variables _privateKey
and _publicKey
to hold the hex values of the keys, and a boolean variable _keysExist
to track whether the keys exist or not. Replace // TODO: Initialize two empty String variables to hold the private and public keys
and // TODO: Initialize a boolean variable to track whether keys exist or not
with the following code:
String _privateKey = '';
String _publicKey = '';
bool _keysExist = false;
We also need to declare and initialize instances of TextEditingController
, GlobalKey
, KeyApi
, and Nip19
to handle user input, form validation, and key generation. Replace // TODO: Declare and initialize instances of TextEditingController, GlobalKey, KeyApi, and Nip19
with the following code:
final _keyController = TextEditingController();
final _formKey = GlobalKey<FormFieldState>();
final _keyGenerator = KeyApi();
final _nip19 = Nip19();
KeyApi()
andNip19()
has been defined in nostr_tools package.
Lastly, we need to initialize a boolean variable _isNotePublishing
by replacing // TODO: Initialize a boolean variable to track if note publishing is in progress
with the following code:
bool _isNotePublishing = false;
These initial properties are crucial for managing the state of the note publishing process and securely storing and managing the keys. In the next section, we'll explore how we can generate new keys and store them securely using the _secureStorage
instance.
Generating New Keys
To implement the generation of new keys, we first need to implement the _addKeysToStorage()
method and then we'll implement generateNewKeys()
method.
First, let's implement the _addKeysToStorage()
method by replacing // TODO: Implement the _addKeysToStorage() method to add keys to secure storage
with the following code:
Future<bool> _addKeysToStorage(
String privateKeyHex,
String publicKeyHex,
) async {
// 1
Future.wait([
_secureStorage.write(key: 'privateKey', value: privateKeyHex),
_secureStorage.write(key: 'publicKey', value: publicKeyHex),
]);
// 2
setState(() {
_privateKey = privateKeyHex;
_publicKey = publicKeyHex;
_keysExist = true;
});
return _keysExist;
}
The _addKeysToStorage()
method writes the private and public keys to a secure storage, using the FlutterSecureStorage
plugin.
-
It uses
Future.wait()
to wait for both write operations to complete. -
It then updates the state variables and triggering a rebuild of the widget.
Now, let's introduce the generateNewKeys()
method by replacing // TODO: Implement the generateNewKeys() method to generate new keys and add them to secure storage
with the following code:
Future<bool> generateNewKeys() async {
final newPrivateKey = _keyGenerator.generatePrivateKey();
final newPublicKey = _keyGenerator.getPublicKey(newPrivateKey);
return await _addKeysToStorage(newPrivateKey, newPublicKey);
}
The generateNewKeys()
method generates new private and public keys using an instance of the _keyGenerator
class, and then adds these keys to the secure storage using the _addKeysToStorage()
method that we implemented above. Finally, it returns a boolean value indicating whether the keys were successfully added to the storage or not.
To call the generateNewKeys()
method, we need to update the modalBottomSheet()
method by replacing /// TODO: Implement logic to generate new keys
with the following code:
generateNewKeyPressed: () {
final currentContext = context;
generateNewKeys().then((keysGenerated) {
if (keysGenerated) {
ScaffoldMessenger.of(currentContext).showSnackBar(
NoostSnackBar(label: 'Congratulations! Keys Generated!'));
}
});
Navigator.pop(context);
},
The updated modalBottomSheet()
method generates new private and public keys when the "Generate New Keys" button is pressed. If the keys are generated successfully, it displays a snackbar with a congratulatory message using ScaffoldMessenger
, and then closes the modal bottom sheet.
Finally, let's update the NoostAppBar
widget with appropriate parameters for keysDialog
by replacing /// TODO: Update the NoostAppBar widget with appropriate keysDialog, and deleteKeysDialog parameters.
as:
appBar: NoostAppBar(
title: 'Noost',
isConnected: _isConnected,
keysDialog: IconButton(
icon: const Icon(Icons.key),
onPressed: () {
// 1
_keysExist
?
// 2
keysExistDialog(
_nip19.npubEncode(_publicKey),
_nip19.nsecEncode(_privateKey),
)
:
// 3
modalBottomSheet();
}),
// TODO: Implement deleteKeysDialog parameter
),
-
The updated
NoostAppBar
widget checks if_keysExist
istrue
, indicating that keys already exist. -
If so, it calls the
keysExistDialog()
function with the appropriate arguments. -
Otherwise, it calls the
modalBottomSheet()
function to generate new keys if keys do not exist.
Time to put the app to the test! Since you've added a new dependency, go ahead and stop the current instance of the app and run it again. (Note: You may not always need to restart when adding dependencies).
Once the app is up and running, click on the "keys" icon in the appbar, and you'll see a modal bottom sheet like this:
Next, click on the "Generate New Key" button within the modal bottom sheet, and watch as new keys are generated and securely stored in the app's storage.
Now, when you click on the "Key" icon again, instead of the bottom sheet, you'll see the keysExistDialog
, indicating that the keys now exist in the app's storage:
Within the keysExistDialog
, you'll notice an icon in the bottom left that resembles an autorenew button. Clicking on this icon will allow you to switch between viewing the keys in hex format or bech32 encoded format, as described in Nip-19. To enable this functionality, simply locate the // TODO: Replace hexPriv and hexPub values
in the keysExistDialog
method, and replace it with the following code snippet:
return KeysExistDialog(
npubEncoded: npubEncode,
nsecEncoded: nsecEncode,
hexPriv: _privateKey,
hexPub: _publicKey,
);
Here, we are passing the _privateKey
and _publicKey
values, which hold the hex formats of the keys.
After making these changes, simply hot reload the app, and you'll see the updated look and feel:
Wait, there's more! To fully test the functionality, let's hot restart the app and click on the "keys" icon button again. You'll notice that this time, the modal bottom sheet appears instead of the keysExistDialog
. This is because we haven't yet implemented the functionality to retrieve the keys from the storage, which we will be covering in the next section.
Retrieving Keys from Secure Storage
To retrieve the keys from the secure storage, we need to implement the _getKeysFromStorage()
method. Replace the existing // TODO: Implement the _getKeysFromStorage() method to retrieve keys from secure storage
with the following code:
Future<void> _getKeysFromStorage() async {
// 1
final storedPrivateKey = await _secureStorage.read(key: 'privateKey');
final storedPublicKey = await _secureStorage.read(key: 'publicKey');
// 2
if (storedPrivateKey != null && storedPublicKey != null) {
setState(() {
_privateKey = storedPrivateKey;
_publicKey = storedPublicKey;
_keysExist = true;
});
}
}
Here's what this code does:
-
We use the
_secureStorage
instance ofFlutterSecureStorage
to read the values associated with the keys 'privateKey' and 'publicKey' from the secure storage. -
Here we're checking if both
storedPrivateKey
andstoredPublicKey
are notnull
, which indicates that both private and public keys are stored in the secure storage and then we're updating the state variables.
Next, we need to call _getKeysFromStorage()
inside the initState()
method to retrieve the keys when the widget is initialized. Replace the // TODO: Call _getKeysFromStorage()
inside of the initState()
method with the following code:
@override
void initState() {
_getKeysFromStorage();
_initStream();
super.initState();
}
In the initState()
method, we're now calling _getKeysFromStorage()
to retrieve the keys from the secure storage. Now, when you hot restart the app and click on the "keys" icon, you will see the keysExistDialog
appropriately displaying the retrieved keys.
In the next section, we will implement the functionality to delete keys from the secure storage. Stay tuned!
Deleting Keys From The Storage
To delete keys from secure storage, we need to implement the _deleteKeysFromStorage()
method. Replace the // TODO: Implement the _deleteKeysFromStorage() method to delete keys from secure storage
with the following code:
Future<void> _deleteKeysFromStorage() async {
// 1
Future.wait([
_secureStorage.delete(key: 'privateKey'),
_secureStorage.delete(key: 'publicKey'),
]);
// 2
setState(() {
_privateKey = '';
_publicKey = '';
_keysExist = false;
});
}
Here's what this code does:
-
Here we're making calls to secure storage to delete the keys from the storage.
-
We're updating the state variables
_privateKey
,_publicKey
and_keysExist
to reset the values after deleting the keys from the storage.
Now, let's implement the logic to call _deleteKeysFromStorage()
in the deleteKeysDialog()
method. Replace // TODO: Implement the logic to delete keys from storage
with the following code:
onYesPressed: () {
final currentContext = context;
_deleteKeysFromStorage().then((_) {
if (!_keysExist) {
ScaffoldMessenger.of(currentContext).showSnackBar(
NoostSnackBar(
label: 'Keys successfully deleted!',
isWarning: true,
),
);
}
});
Navigator.pop(context);
},
In this code, we're calling _deleteKeysFromStorage()
method when the "Yes" button is pressed in the deleteKeysDialog
. After deleting the keys, if _keysExist
is false
, we're displaying a snackbar with a success message, and then closing the dialog using Navigator.pop(context)
.
Finally, let's update the deleteKeysDialog
parameter for the appbar
. Replace // TODO: Implement deleteKeysDialog parameter
with the following code:
deleteKeysDialog: _keysExist
? IconButton(
icon: const Icon(Icons.delete),
onPressed: () => deleteKeysDialog(),
)
: Container(),
In this code, we're checking if _keysExist
is true
, then only we're showing the deleteKeysDialog()
as an IconButton
with a delete icon in the top right of the appbar. Otherwise, we're displaying an empty container.
After making these changes, hot restart the app, and you will see the delete icon in the appbar.
Clicking on the delete icon will open the deleteKeysDialog()
as shown in the screenshot below.
Clicking "Yes" on the dialog will successfully delete the keys from the device.
Now, up to this point in the tutorial, you've learned how to generate new keys. But what if the user is coming from a different client and wants to enter their private key to start using Noost from then on? Let's implement that next!
Handling Input of Private Key
Locate // TODO: Showing PastePrivateKeyDialog to handle the input
which is inside of the pastePrivateKeyDialog()
method and replace it as:
void pastePrivateKeyDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return PastePrivateKeyDialog(
keyController: _keyController,
formKey: _formKey,
// 1
keyValidator: (value) {
// TODO: Validating privateKey entered by the user
return null;
},
// 2
onOKPressed: () {
if (_formKey.currentState!.validate()) {
// TODO: Adding user entered private key to the storage.
Navigator.pop(context);
} else {
_formKey.currentState?.setState(() {});
}
},
onCancelPressed: () {
Navigator.pop(context);
},
);
},
);
}
The pastePrivateKeyDialog()
method is responsible for displaying a dialog box with a custom widget called PastePrivateKeyDialog
when called. Here's how it works:
-
The
keyValidator
function is used for validating the private key entered by the user. We'll implement the validation logic further. -
If the form represented by
_formKey
is validated, theonOKPressed
callback function is called.
Next, Locate // TODO: Validating privateKey entered by the user
and replace it as:
keyValidator: (value) {
// 1
if (value == null || value.isEmpty) {
return 'Please enter your private key.';
}
try {
// 2
bool isValidHexKey = _keyGenerator.isValidPrivateKey(value);
// 3
bool isValidNsec = value.trim().startsWith('nsec') &&
_keyGenerator.isValidPrivateKey(_nip19.decode(value)['data']);
// 4
if (!(isValidHexKey || isValidNsec)) {
return 'Your private key is not valid.';
}
// 5
} on ChecksumVerificationException catch (e) {
return e.message;
// 6
} catch (e) {
return 'Error: $e';
}
// 7
return null;
}
Here's how the validation check of the user-entered private key works:
-
It first checks if the
value
isnull
or empty, and if so, it returns the appropriate error message. -
We use the
isValidPrivateKey(value)
method to validate the user-entered private key. This method is defined in the nostr_tools Dart package and only accepts a hexadecimal value of the private key. -
If the user entered a bech32 encoded private key in the "nsec" format as described in Nip-19, we first decode it and then pass the decoded value (which is the hexadecimal value of the private key) to the
isValidPrivateKey(...)
method. -
If the value is not a valid private key in either hexadecimal or Nip-19 format, it returns the appropriate error message.
-
If the value fails the checksum verification using a
ChecksumVerificationException
, it returns the error message from the exception. -
If any other exception occurs during the validation process, it returns an error message with the exception's error message.
-
If the value passes all the validation checks, it returns
null
, indicating that thevalue
is valid.
That's it for the validation check of the private key. Next locate // TODO: Adding user entered private key to the storage
, and replace it as:
onOKPressed: () {
if (_formKey.currentState!.validate()) {
// 1
String privateKeyHex = _keyController.text.trim();
String publicKeyHex;
// 2
if (privateKeyHex.startsWith('nsec')) {
final decoded = _nip19.decode(privateKeyHex);
privateKeyHex = decoded['data'];
publicKeyHex = _keyGenerator.getPublicKey(privateKeyHex);
}
// 3
else {
publicKeyHex = _keyGenerator.getPublicKey(privateKeyHex);
}
// 4
_addKeysToStorage(privateKeyHex, publicKeyHex).then((keysAdded) {
if (keysAdded) {
_keyController.clear();
ScaffoldMessenger.of(context).showSnackBar(
NoostSnackBar(label: 'Congratulations! Keys Stored!'),
);
}
});
Navigator.pop(context);
} else {
_formKey.currentState?.setState(() {});
}
},
Let's break down what the code does:
-
It obtains the value of the private key from the
_keyController
text field and assigns it to theprivateKeyHex
variable. -
It checks if the
privateKeyHex
starts with the string "nsec", indicating that it might be in NIP-19 format. If so, it decodes theprivateKeyHex
using_nip19.decode(privateKeyHex)
method to obtain the 'data' field, which represents the actual private key in hexadecimal format. -
If the
privateKeyHex
does not start with "nsec", indicating that it is a regular hexadecimal private key. -
It then calls the
_addKeysToStorage
method to add the private key and public key to the storage. It attaches athen()
callback to this method to handle the case when the keys are successfully added to the storage.
To see this in action, locate /// TODO: Implement logic to handle input of private key
inside the modalBottomSheet()
method and replace it with the following:
inputPrivateKeyPressed: () {
Navigator.pop(context);
_keyController.clear();
pastePrivateKeyDialog();
},
Now, perform a hot restart of the app. Click on the "keys" icon and then click on the "Input your private key" option in the modal bottom sheet. You will see the pastePrivateKeyDialog
with a text form field to input the private key, like this:
Try clicking on the "OK" button without entering anything into the TextFormField
, and you will see the appropriate error message displayed, like this:
If you enter some irrelevant information, it will invalidate it and show you the appropriate error message, like this:
Even if your private key is corrupted with some data, our code will catch and display that as well, like this:
And if your private key is valid, it will store your key into the storage and display a snackbar with a congratulatory message.
By now, you've learned a lot about generating and managing keys in this tutorial. But there's more to explore in key management in nostr! In the next sections, we'll focus on implementing the pull-to-refresh functionality in our app and finally how to publish events.
Implementing Pull To Refresh
To enable the pull-to-refresh feature, we need to implement a method that will help us resubscribe to the stream. Locate the comment // TODO: Implement the _resubscribeStream method to initialize a stream.
, which is located just above the build
method, and replace it with the following code:
Future<void> _resubscribeStream() async {
await Future.delayed(const Duration(seconds: 1), () {
setState(() {
_events.clear();
_metaDatas.clear();
});
_initStream(); // Reconnect and resubscribe to the filter
});
}
In the above _resubscribeStream()
method, after a delay of 1 second, we clear the _events
and _metaDatas
collections. Then, we call _initStream()
method, which is responsible for initializing and subscribing to a stream, to reconnect and resubscribe to the filter.
Next, locate the comment /// TODO: Implement the RefreshIndicator widget and its onRefresh callback to handle refreshing of the page.
, and replace the entire body by wrapping the StreamBuilder
widget in a RefreshIndicator
widget as follows:
// 1
body: RefreshIndicator(
// 2
onRefresh: () async {
await _resubscribeStream();
},
child: StreamBuilder(
....
),
),
Here's what it does:
-
The
RefreshIndicator
widget provides pull-to-refresh behavior to its child widget, allowing the user to trigger a refresh action by pulling down on the screen. -
The
onRefresh
property ofRefreshIndicator
is set to an asynchronous callback function that gets executed when the user triggers the refresh action. In this case, the callback function is_resubscribeStream()
, which is a Future-based method that performs asynchronous operations, clearing and resubscribing to a stream.
Now, hot restart your app and test the pull-to-refresh feature to see if it's working appropriately.
Next, let's move on to implementing the functionality to publish events in the next section.
Publishing Events
Let's make it easy for users to publish their notes by adding a button that displays a text field for entering the note. First, locate the /// TODO: Implement the CreatePostFAB widget to enable users to create new Noosts.
comment and replace it with the following code:
// 1
floatingActionButton: _keysExist
?
// 2
CreatePostFAB(
publishNote: (note) {
// TODO: Publish Note functionality
Navigator.pop(context);
},
isNotePublishing: _isNotePublishing,
)
:
// 3
Container(),
Here's what the code does:
-
If
_keysExist
istrue
, thefloatingActionButton
is built using theCreatePostFAB
widget. This custom widget represents a FAB with specific decoration and widgets for publishing a note. -
The
CreatePostFAB
widget is configured with two properties:publishNote
andisNotePublishing
. We will implement the publish note functionality soon. TheisNotePublishing
property indicates whether the note is currently being published, and it is likely used to display a progress indicator on the FAB during the publishing process. -
If
_keysExist
isfalse
, an emptyContainer()
widget is displayed, meaning that the FAB will not be visible.
Hot reload the app and you will see the FAB as shown in the screenshot below:
When you click on the floating action button, a dialog will appear, prompting you to enter the note, as shown in the screenshot below:
With these modifications, your users will be able to easily publish their notes by simply clicking on the FAB and entering their note in the provided text field.
Now let's implement the publishing note functionality by replacing // TODO: Publish Note functionality
with the following code:
// 1
publishNote: (note) {
setState(() => _isNotePublishing = true);
// 2
final eventApi = EventApi();
// 3
final event = eventApi.finishEvent(
// 4
Event(
kind: 1,
tags: [
['t', 'nostr']
],
content: note!,
created_at: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
_privateKey,
);
// 5
if (eventApi.verifySignature(event)) {
try {
// 6
_relay.publish(event);
// 7
_resubscribeStream();
// 8
ScaffoldMessenger.of(context).showSnackBar(
NoostSnackBar(label: 'Congratulations! Noost Published!'),
);
} catch (_) {
// 9
ScaffoldMessenger.of(context).showSnackBar(NoostSnackBar(
label: 'Oops! Something went wrong!',
isWarning: true,
));
}
}
setState(() => _isNotePublishing = false);
Navigator.pop(context);
},
There's a lot happening in here. Let's break it down:
-
The
publishNote
function is called when the user triggers the "Noost!" button on the dialog. -
An instance of the
EventApi
class is created, which is defined in thenostr_tools
package. -
The
finishEvent
method of theEventApi
class is called with theEvent
object and the_privateKey
variable.finishEvent
will set theid
of the event with the event hash and will sign the event with the given_privateKey
.
-
Here, we're creating a new instance of the
Event
class with specific properties such as:-
kind: 1
: Which means a simple text note. -
tags: [['t', 'nostr']]
: Its data type isList<List<String>>
, which means if we want to add another tag, we need to update its value astags: [['t', 'nostr'], ['t', 'bitcoin']]
. But for our event, we're simply using the "nostr" tag because we're filtering the feed in our app with "nostr". So when our event is published, it will be super easy to reflect that in our app, only if we publish the event with a tag of "nostr". -
content: note!
: It's set to the plaintext content of a note (anything the user wants to say). -
created_at
: It's set to the current Unix timestamp in seconds.
-
-
The
verifySignature
method of theEventApi
class is called with the returnedEvent
object to verify the signature of the event. -
If the signature is verified, the
publish
method is called on the_relay
object to publish the event. -
After publishing the event, the
_resubscribeStream
method is called, likely to refresh the stream or subscription to reflect the newly published event. -
A
SnackBar
is shown to display a message indicating that the note was successfully published. -
If any error occurs during the publishing process (e.g., an exception is caught), a warning
SnackBar
is shown instead.
Now hot restart your app and let's publish a new event for the first time from Noost!
Phew! That was a lot of work, but you did it. Pretty amazing!
Congratulations, you've made it to the end of part 2 of this tutorial! Give yourself a pat on the back, because that was a lot of work, but you did it. You're now well on your way to becoming a Nostr pro dev!
By now, you should have a fully-functional Nostr app that can connect seamlessly to the Nostr relay, generate new keys with ease, and even allow you to input your own private key. You've learned how to store your keys securely, and you've implemented a convenient pull-to-refresh feature. Plus, you're now ready to publish your own Noosts and share your thoughts with the world.
But that's not all! In part 3 of this tutorial, we'll take things to the next level. We'll explore how to interact with multiple relays, opening up new possibilities for your app. And we'll even delve into publishing kind 0 events, unlocking exciting capabilities for your Noost app.
If you have any questions or run into any issues, don't hesitate to ask. I'm here to help! You can leave your questions in the comments section below or send me a direct message on Nostr. I'm always happy to assist you on your Nostr app development journey.
And if you're eager to dive into the complete code, head over to the final directory on GitHub. I hope you've found this tutorial helpful, and I'm grateful that you've been following along. Stay tuned for part 3, where we'll take your Nostr app to new heights. Keep up the fantastic work!