import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:drifter/domain_models/domain_models.dart'; import 'package:drifter/models/models.dart'; import 'package:drifter/theme/app_colors.dart'; import 'package:drifter/pages/home_screen/widgets/message_ok_button_widget.dart'; import 'package:drifter/pages/home_screen/widgets/message_text_button_widget.dart'; import 'package:drifter/pages/home_screen/widgets/message_text_form_field_widget.dart'; import 'package:drifter/pages/profile_screen/widgets/message_snack_bar.dart'; import 'package:nostr_tools/nostr_tools.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { bool _isConnected = false; final List _events = []; final Map _metaDatas = {}; late Stream _stream; final _controller = StreamController(); bool _isNotePublishing = false; @override void initState() { _initStream(); super.initState(); } Future> _connectToRelay() async { final stream = await Relay.relay.connect(); Relay.relay.on((event) { if (event == RelayEvent.connect) { setState(() => _isConnected = true); } else if (event == RelayEvent.error) { setState(() => _isConnected = false); } }); Relay.relay.sub([ Filter( kinds: [1], limit: 100, t: ['nostr'], ) ]); return stream .where((message) => message.type == 'EVENT') .map((message) => message.message); } void _initStream() async { _stream = await _connectToRelay(); _stream.listen((message) { final event = message; if (event.kind == 1) { setState(() => _events.add(event)); Relay.relay.sub([ Filter(kinds: [0], authors: [event.pubkey]) ]); } else if (event.kind == 0) { final metadata = Metadata.fromJson(jsonDecode(event.content)); setState(() => _metaDatas[event.pubkey] = metadata); } _controller.add(event); }); } // The _resubscribeStream() method clears the _events and _metaData scollection after a 1 second delay Future _resubscribeStream() async { await Future.delayed(const Duration(seconds: 1), () { setState(() { _events.clear(); _metaDatas.clear(); }); // _initStream() method, responsible for initializing and subscribing to the stream, to reconnect and re-subscribe to the filter. _initStream(); }); } @override Widget build(BuildContext context) { return Scaffold( body: RefreshIndicator( onRefresh: () async { await _resubscribeStream(); }, child: StreamBuilder( stream: _controller.stream, builder: (context, snapshot) { // Inside the builder callback, the snapshot object contains the most recent event from the thread. // If snapshot.hasData is true, there is data to display. In this case, ListView.builder is returned, which displays a list of NoostCard widgets. if (snapshot.hasData) { return ListView.builder( // The itemCount property of ListView.builder is set to _events.length, , which is the number of events in the _events list. itemCount: _events.length, itemBuilder: (context, index) { final event = _events[index]; final metadata = _metaDatas[event.pubkey]; // For each event, a Noost object is created that encapsulates the details of the event, including id, avatarUrl, name,username, time, content и pubkey. // _metaDatas, you can map the event public key to the author's metadata. final domain = Domain( noteId: event.id, avatarUrl: metadata?.picture ?? 'https://robohash.org/${event.pubkey}', name: metadata?.name ?? 'Anon', username: metadata?.displayName ?? (metadata?.display_name ?? 'Anon'), time: TimeAgo.format(event.created_at), content: event.content, pubkey: event.pubkey, ); return DomainCard(domain: domain); }, ); } else if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: Text('Loading....')); } else if (snapshot.hasError) { return Center(child: Text('Error: ${snapshot.error}')); } return const CenteredCircularProgressIndicator(); }, ), ), floatingActionButton: Keys.keysExist ? CreatePost( // The publishNote function is called when the user launches the "Noost!" button in the dialog box. publishNote: (note) { setState(() => _isNotePublishing = true); // EventApi Creates an instance of the class defined in the nostr_tools package. final eventApi = EventApi(); // The finishEvent method of the EventApi class is called with the Event object and the _privateKey variable. // finishEvent will set the event id with the event hash and sign the event with the given _privateKey. final event = eventApi.finishEvent( // This creates a new instance of the Event class with certain properties, such as: Event( kind: 1, tags: [ ['t', 'nostr'] ], content: note!, created_at: DateTime.now().millisecondsSinceEpoch ~/ 1000, ), Keys.publicKey, ); if (eventApi.verifySignature(event)) { try { // If the signature is verified, the _relay method is called for the object to publish the event. Relay.relay.publish(event); // After the _resubscribeStream event is published, a method is called that will probably update the stream or subscription to reflect the recently published event. _resubscribeStream(); // Show SnackBar to display a message that the note has been successfully published. ScaffoldMessenger.of(context).showSnackBar( MessageSnackBar( label: 'Congratulations! Noost Published!'), ); } catch (_) { // If an error occurs during the publishing process (e.g., an exception is caught), SnackBar displays a warning instead. ScaffoldMessenger.of(context).showSnackBar(MessageSnackBar( label: 'Oops! Something went wrong!', isWarning: true, )); } } setState(() => _isNotePublishing = false); Navigator.pop(context); }, isNotePublishing: _isNotePublishing, ) : // If _keysExist is false, then an empty widget is displayed, which means that the FAB will not be visible. Container() Container(), ); } } class TimeAgo { static String format(int timestamp) { DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); Duration difference = DateTime.now().difference(dateTime); String timeAgo = ''; if (difference.inDays > 0) { timeAgo = '${difference.inDays} ${difference.inDays == 1 ? 'day' : 'days'} ago'; } else if (difference.inHours > 0) { timeAgo = '${difference.inHours} ${difference.inHours == 1 ? 'hour' : 'hours'} ago'; } else if (difference.inMinutes > 0) { timeAgo = '${difference.inMinutes} ${difference.inMinutes == 1 ? 'minute' : 'minutes'} ago'; } else { timeAgo = 'just now'; } return timeAgo; } } class DomainCard extends StatelessWidget { const DomainCard({ super.key, required this.domain, }); final Domain domain; List? extractImage(String text) { final RegExp exp = RegExp( r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|gif|png|jpeg)", caseSensitive: false, multiLine: true, ); final Iterable matches = exp.allMatches(text); final List imageLinks = matches.map((match) => match.group(0)!).toList(); return imageLinks.isNotEmpty ? imageLinks : null; } @override Widget build(BuildContext context) { final List? imageLinks = extractImage(domain.content); return Container( margin: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.grey, borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( leading: CircleAvatar( backgroundImage: FadeInImage( placeholder: const NetworkImage('https://i.ibb.co/mJkxDkb/satoshi.png'), image: NetworkImage(domain.avatarUrl), ).image, ), title: Text(domain.name, style: const TextStyle(color: Colors.white)), subtitle: Text('@${domain.username.toLowerCase()} • ${domain.time}', style: TextStyle(color: Colors.grey.shade400)), trailing: const Icon(Icons.more_vert, color: Colors.grey), ), Divider(height: 1, color: Colors.grey.shade400), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text(domain.content, style: const TextStyle(color: Colors.white)), ), if (imageLinks != null && imageLinks.isNotEmpty) Center( child: Stack( children: [ const Placeholder( fallbackHeight: 200, color: Colors.transparent, ), Center( child: FadeInImage( placeholder: const NetworkImage( 'https://i.ibb.co/D9jqXgR/58038897-167f0280-7ae6-11e9-94eb-88e880a25f0f.gif', ), image: NetworkImage(imageLinks.first), fit: BoxFit.cover, ), ), ], ), ), ], ), ); } } class CenteredCircularProgressIndicator extends StatelessWidget { const CenteredCircularProgressIndicator({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return const Center( child: CircularProgressIndicator(), ); } } class CreatePost extends StatefulWidget { const CreatePost({ Key? key, required this.publishNote, required this.isNotePublishing, }) : super(key: key); final Function(String?) publishNote; final bool isNotePublishing; @override State createState() => _CreatePostState(); } class _CreatePostState extends State { final _noteController = TextEditingController(); final GlobalKey _formKey = GlobalKey(); @override Widget build(BuildContext context) { return FloatingActionButton( backgroundColor: Colors.deepPurpleAccent, tooltip: 'Create a new post', elevation: 2, highlightElevation: 4, foregroundColor: Colors.white, child: Stack( alignment: Alignment.center, children: [ Container( width: 60, height: 60, decoration: const BoxDecoration( shape: BoxShape.circle, color: AppColors.mainDarkBlue), ), const Icon( Icons.add, color: Colors.white, ), ], ), onPressed: () async { _noteController.clear(); await showDialog( barrierDismissible: false, context: context, builder: ((context) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Container( constraints: const BoxConstraints(maxWidth: 600), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: Colors.white, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.symmetric(vertical: 24), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: AppColors.background, ), child: const Center( child: Text( 'Create a Noost', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white, ), ), ), ), const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: MessageTextFormField( maxLines: 5, hintText: 'Type your Noost here...', controller: _noteController, formKey: _formKey, validator: (value) { if (value == null || value.trim().isEmpty) { return 'Please enter your note.'; } return null; }, ), ), const SizedBox(height: 24), widget.isNotePublishing ? const CenteredCircularProgressIndicator() : Row( mainAxisAlignment: MainAxisAlignment.end, children: [ MessageTextButton( onPressed: () { Navigator.pop(context); }, label: 'Cancel', ), const SizedBox(width: 16), MessageOkButton( onPressed: () { if (_formKey.currentState!.validate()) { widget.publishNote( _noteController.text.trim()); } else { _formKey.currentState?.setState(() {}); } }, label: 'Noost!', ), const SizedBox(width: 24), ], ) ], ), ), ); }), ); }, ); } }