diff --git a/lib/config/default_suggestions.dart b/lib/config/default_suggestions.dart new file mode 100644 index 0000000..1b1c5b5 --- /dev/null +++ b/lib/config/default_suggestions.dart @@ -0,0 +1,10 @@ +const List> defaultHashtagSuggestions = [ + {"id": "asknostr", "display": "askNostr"}, + {"id": "introductions", "display": "introductions"}, + {"id": "photography", "display": "photography"}, + {"id": "news", "display": "news"}, + {"id": "asknostr", "display": "askNostr"}, + {"id": "trending", "display": "trending"}, + {"id": "followfriday", "display": "FollowFriday"}, + {"id": "photooftheday", "display": "PhotoOfTheDay"}, +]; diff --git a/lib/presentation_layer/components/write_post.dart b/lib/presentation_layer/components/write_post.dart index 09ea400..d42ef56 100644 --- a/lib/presentation_layer/components/write_post.dart +++ b/lib/presentation_layer/components/write_post.dart @@ -1,15 +1,9 @@ import 'dart:developer'; import 'dart:io'; -import 'package:camelus/domain_layer/entities/nostr_note.dart'; import 'package:camelus/domain_layer/entities/user_metadata.dart'; import 'package:camelus/presentation_layer/atoms/picture.dart'; -import 'package:camelus/helpers/nprofile_helper.dart'; -import 'package:camelus/domain_layer/entities/nostr_tag.dart'; -import 'package:camelus/presentation_layer/providers/edit_relays_provider.dart'; -import 'package:camelus/presentation_layer/providers/event_signer_provider.dart'; -import 'package:camelus/presentation_layer/providers/file_upload_provider.dart'; -import 'package:camelus/presentation_layer/providers/get_notes_provider.dart'; + import 'package:camelus/presentation_layer/providers/metadata_state_provider.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -21,8 +15,10 @@ import 'package:camelus/config/palette.dart'; import 'package:camelus/helpers/helpers.dart'; import 'package:camelus/data_layer/models/post_context.dart'; -import '../../domain_layer/entities/mem_file.dart'; +import '../../config/default_suggestions.dart'; + import '../../domain_layer/usecases/remove_image_metadata.dart'; +import '../providers/write_post_state.provider.dart'; class WritePost extends ConsumerStatefulWidget { final PostContext? context; @@ -39,13 +35,8 @@ class _WritePostState extends ConsumerState { GlobalKey(); final FocusNode _focusNode = FocusNode(); - bool submitLoading = false; - - final List _images = []; List> _mentionsSearchResults = []; List> _mentionsSearchResultsHashTags = []; - List _mentionedInPost = []; - List _hashtagsInPost = []; _addImage() async { FilePickerResult? result = await FilePicker.platform.pickFiles( @@ -58,9 +49,7 @@ class _WritePostState extends ConsumerState { try { final myImage = await RemoveImageMetadata.fileToMemFile( File(result.files.single.path!)); - setState(() { - _images.add(myImage); - }); + ref.read(writePostStateProvider.notifier).addImage(myImage); } catch (e) { if (!mounted) return; @@ -77,10 +66,11 @@ class _WritePostState extends ConsumerState { } _searchMentions(search) async { + final writePostState = ref.read(writePostStateProvider); List> results = []; var rawResults = []; //_search.searchUsersMetadata(search); - for (var rawResult in rawResults) { + for (final rawResult in rawResults) { var result = { "id": rawResult.pubkey, "pubkey": rawResult.pubkey, @@ -93,7 +83,7 @@ class _WritePostState extends ConsumerState { } // keep data from already mentioned users - for (var mention in _mentionedInPost) { + for (final mention in writePostState.mentionedInPost) { if (results.any((element) => element['id'] == mention)) { continue; } @@ -124,231 +114,13 @@ class _WritePostState extends ConsumerState { _searchHashtags(String search) async { List> results = []; - results = [ - { - "id": "todo", - "display": search, - } - ]; + results = defaultHashtagSuggestions; setState(() { _mentionsSearchResultsHashTags = results; }); } - _extractMentions(String markupText) { - final mentionKeys = []; - final keyRegex = RegExp(r'@\[__(.*?)__\]'); - - markupText.replaceAllMapped(keyRegex, (match) { - mentionKeys.add(match.group(1)!); - return ''; - }); - - setState(() { - _mentionedInPost = mentionKeys; - }); - } - - _extractHashtags(String markupText) { - final hashtagKeys = []; - final keyRegex = RegExp(r'#\w+'); - - markupText.replaceAllMapped(keyRegex, (match) { - hashtagKeys.add(match.group(0)!); - return ''; - }); - - setState(() { - _hashtagsInPost = hashtagKeys; - }); - } - - _submitPost() async { - var textController = _textEditingControllerKey.currentState!.controller; - - if (textController!.text == "") { - return; - } - - setState(() { - submitLoading = true; - }); - - var markupText = textController.markupText; - - // extract mentions from markupText - - final mentionKeys = []; - final keyRegex = RegExp(r'@\[__(.*?)__\]'); - - String output = markupText.replaceAllMapped(keyRegex, (match) { - mentionKeys.add(match.group(1)!); - var userHex = match.group(1)!; - - var nprofile = - NprofileHelper().mapToBech32({'pubkey': userHex, 'relays': []}); - return 'nostr:$nprofile '; - }); - - output = output.replaceAllMapped(RegExp(r'\(__.*?\)'), (match) { - return ''; - }); - - var content = output; - - List tags = []; - - if (widget.context != null) { - var replyIsReplyToRoot = widget.context!.replyToNote.getRootReply; - if (replyIsReplyToRoot != null) { - var tag = NostrTag( - type: "e", - value: replyIsReplyToRoot.value, - recommended_relay: "", - marker: "root", - ); - tags.add(tag); - } else { - // is reply to root - var tag = NostrTag( - type: "e", - value: widget.context!.replyToNote.id, - recommended_relay: "", - marker: "root", - ); - tags.add(tag); - var tagPubkey = NostrTag( - type: "p", - value: widget.context!.replyToNote.pubkey, - recommended_relay: "", - marker: "root", - ); - tags.add(tagPubkey); - } - - // add previous tweet tags - for (NostrTag tag in widget.context!.replyToNote.tags) { - if (tag.type == "e") { - if (tag.marker == "root" || tag.marker == "reply") { - continue; - } - if (!(tags.map((e) => e.value).contains(tag.value))) { - tags.add(tag); - } - } - if (tag.type == "p") { - if (tag.marker == "root" || tag.marker == "reply") { - continue; - } - - tags.add(tag); - } - } - - if (mentionKeys.isNotEmpty) { - for (int i = 0; i < mentionKeys.length; i++) { - var pubkey = mentionKeys[i]; - final editRelayProvider = ref.watch(editRelaysProvider); - - var potentialRelays = - await editRelayProvider.getRelayHintsInbox(pubkey); - - tags.add(NostrTag( - type: "p", - value: pubkey, - recommended_relay: potentialRelays.firstOrNull?.url ?? "", - marker: "mention", - )); - } - } - - if (widget.context != null) { - var tag = NostrTag( - type: "e", - value: widget.context!.replyToNote.id, - recommended_relay: "", - marker: "reply", - ); - tags.add(tag); - - var tagPubkey = NostrTag( - type: 'p', - value: widget.context!.replyToNote.pubkey, - recommended_relay: '', - marker: 'reply', - ); - tags.add(tagPubkey); - } - } - - // add hashtags - for (var hashtag in _hashtagsInPost) { - tags.add( - NostrTag( - type: "t", - value: hashtag.toLowerCase().substring(1), - ), - ); - } - - // upload images - List imageUrls = []; - for (var image in _images) { - try { - var url = await ref.watch(fileUploadProvider).uploadImage(image); - imageUrls.add(url); - } catch (e) { - log("errUploadImage: ${e.toString()}"); - } - } - - // add image urls to content - content += "\n"; - for (var url in imageUrls) { - content += " $url"; - } - - final notesP = ref.watch(getNotesProvider); - - final signerP = ref.watch(eventSignerProvider); - if (signerP == null) { - _showErrorMsg("no signer"); - return; - } - final pubkey = signerP.getPublicKey(); - - final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - await notesP - .broadcastNote(NostrNote( - id: '', - pubkey: pubkey, - created_at: now, - kind: 1, - content: content, - sig: '', - tags: tags, - )) - .onError( - (error, stackTrace) { - _showErrorMsg('Error broadcasting note: $error'); - return; - }, - ); - - setState(() { - submitLoading = false; - }); - - // wait for x seconds - Future.delayed(const Duration(milliseconds: 100), () { - if (!mounted) return; - // close modal - Navigator.pop(context); - }); - } - _showErrorMsg(String msg) { // alert dialog showDialog( @@ -394,11 +166,37 @@ class _WritePostState extends ConsumerState { @override Widget build(BuildContext context) { + final writePostState = ref.watch(writePostStateProvider); + final writePostNotifier = ref.read(writePostStateProvider.notifier); + return Column( mainAxisSize: MainAxisSize.min, children: [ // horizontal line fading out to both sides + if (writePostState.isError) + Column( + children: [ + const SizedBox( + height: 20, + ), + Text("error:", + style: TextStyle( + color: Palette.white, + fontWeight: FontWeight.bold, + )), + SizedBox( + height: 5, + ), + Text( + writePostState.errorText, + ), + SizedBox( + height: 20, + ) + ], + ), + Container( width: double.infinity, //height: MediaQuery.of(context).size.height * 0.4, @@ -421,9 +219,14 @@ class _WritePostState extends ConsumerState { ), _TopBar( - replyToPubkey: widget.context?.replyToNote.pubkey, - submitLoading: submitLoading, - submitPostCallback: _submitPost, + replyToPubkey: writePostState.replyToNote?.pubkey, + submitLoading: writePostState.isSubmitting, + submitPostCallback: () => writePostNotifier.submitPost().then( + (value) { + if (!mounted) return; + Navigator.pop(context); + }, + ), ), const SizedBox( height: 20, @@ -431,7 +234,7 @@ class _WritePostState extends ConsumerState { // large text field _writingArea(), // image preview - if (_images.isNotEmpty) _previewImages(), + if (writePostState.images.isNotEmpty) _previewImages(), // bottom row _bottomRow(), @@ -448,11 +251,12 @@ class _WritePostState extends ConsumerState { } SizedBox _previewImages() { + final images = ref.watch(writePostStateProvider).images; return SizedBox( height: 100, child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: _images.length, + itemCount: images.length, itemBuilder: (BuildContext context, int index) { return Stack( children: [ @@ -461,7 +265,7 @@ class _WritePostState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.memory( - _images[index].bytes, + images[index].bytes, fit: BoxFit.cover, width: 100, height: 100, @@ -474,13 +278,16 @@ class _WritePostState extends ConsumerState { child: TextButton( onPressed: (() { setState(() { - _images.removeAt(index); + images.removeAt(index); }); }), child: SvgPicture.asset( height: 25, 'assets/icons/x.svg', - color: Palette.gray, + colorFilter: const ColorFilter.mode( + Palette.gray, + BlendMode.srcIn, + ), ), ), ), @@ -524,14 +331,6 @@ class _WritePostState extends ConsumerState { // on bottom ], ), - if (_images.isNotEmpty) - Container( - margin: const EdgeInsets.only(left: 10, right: 10), - child: const Text( - "provided by nostr.build", - style: TextStyle(color: Palette.lightGray, fontSize: 11), - ), - ), ], ); } @@ -566,9 +365,8 @@ class _WritePostState extends ConsumerState { log("mention added: $p0"); }, onMarkupChanged: (p0) { - // triggers when something is typed in the text field - _extractMentions(p0); - _extractHashtags(p0); + // triggers when something is typed in the text fields + ref.read(writePostStateProvider.notifier).updateMarkup(p0); }, onSearchChanged: (String trigger, search) { if (search.isNotEmpty && trigger == "@") { @@ -633,7 +431,28 @@ class _WritePostState extends ConsumerState { ), Mention( suggestionBuilder: (data) { - return Container(); + return Container( + padding: const EdgeInsets.all(10.0), + child: Row( + children: [ + const SizedBox( + width: 20.0, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data['display'] != null ? "#${data['display']}" : "", + style: const TextStyle( + color: Palette.lightGray, + fontSize: 20, + ), + ), + ], + ) + ], + ), + ); }, trigger: "#", matchAll: true, @@ -698,8 +517,6 @@ class _TopBar extends ConsumerWidget { if (replyToPubkey != null) Column( children: [ - // get metadata - SizedBox( width: MediaQuery.of(context).size.width * 0.6, child: Container( @@ -716,21 +533,6 @@ class _TopBar extends ConsumerWidget { ), ), ), - - /// check if replying to multiple people - // if ((Helpers() - // .getPubkeysFromTags( - // widget.context?.replyToTweet.tags ?? []) - // .length > - // 1)) - // Text( - // "and ${Helpers().getPubkeysFromTags(widget.context?.replyToTweet.tags ?? []).length - 1} more", - // style: const TextStyle( - // color: Palette.lightGray, - // fontSize: 16, - // fontWeight: FontWeight.normal, - // ), - // ), ], ), // if submitLoading is true, show spinner diff --git a/lib/presentation_layer/providers/write_post_state.provider.dart b/lib/presentation_layer/providers/write_post_state.provider.dart new file mode 100644 index 0000000..a253788 --- /dev/null +++ b/lib/presentation_layer/providers/write_post_state.provider.dart @@ -0,0 +1,281 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain_layer/entities/mem_file.dart'; +import '../../domain_layer/entities/nostr_note.dart'; +import '../../domain_layer/entities/nostr_tag.dart'; +import '../../helpers/nprofile_helper.dart'; +import 'edit_relays_provider.dart'; +import 'event_signer_provider.dart'; +import 'file_upload_provider.dart'; +import 'get_notes_provider.dart'; + +final writePostStateProvider = + NotifierProvider( + WritePostNotifier.new, +); + +class WritePostState { + final List images; + final NostrNote? replyToNote; + List mentionedInPost; + List hashtagsInPost; + + final bool isSubmitting; + final bool isError; + final String errorText; + final List> uploadTasks; + + final String markupText; + + WritePostState({ + required this.markupText, + this.isError = false, + this.errorText = '', + this.isSubmitting = false, + this.uploadTasks = const [], + this.images = const [], + this.replyToNote, + this.mentionedInPost = const [], + this.hashtagsInPost = const [], + }); + + copyWith({ + List? images, + NostrNote? replyToNote, + List? mentionedInPost, + List? hashtagsInPost, + bool? isSubmitting, + List>? uploadTasks, + String? markupText, + bool? isError, + String? errorText, + }) { + return WritePostState( + images: images ?? this.images, + replyToNote: replyToNote ?? this.replyToNote, + mentionedInPost: mentionedInPost ?? this.mentionedInPost, + hashtagsInPost: hashtagsInPost ?? this.hashtagsInPost, + isSubmitting: isSubmitting ?? this.isSubmitting, + uploadTasks: uploadTasks ?? this.uploadTasks, + markupText: markupText ?? this.markupText, + isError: isError ?? this.isError, + errorText: errorText ?? this.errorText, + ); + } +} + +class WritePostNotifier extends Notifier { + addImage( + MemFile image, + ) { + state = state.copyWith(images: [...state.images, image]); + } + + updateMarkup(String markupText) { + state = state.copyWith(markupText: markupText); + extractMentions(); + extractHashtags(); + } + + extractMentions() { + final mentionKeys = []; + final keyRegex = RegExp(r'@\[__(.*?)__\]'); + + state.markupText.replaceAllMapped(keyRegex, (match) { + mentionKeys.add(match.group(1)!); + return ''; + }); + + state.mentionedInPost = mentionKeys; + } + + extractHashtags() { + final hashtagKeys = []; + final keyRegex = RegExp(r'#\w+'); + + state.markupText.replaceAllMapped(keyRegex, (match) { + hashtagKeys.add(match.group(0)!); + return ''; + }); + + state.hashtagsInPost = hashtagKeys; + } + + Future submitPost() async { + if (state.isSubmitting) return; + if (state.markupText.isEmpty) return; + + state = state.copyWith(isSubmitting: true); + + // extract mentions from markupText + extractMentions(); + extractHashtags(); + + final mentionKeys = []; + final keyRegex = RegExp(r'@\[__(.*?)__\]'); + + String output = state.markupText.replaceAllMapped(keyRegex, (match) { + mentionKeys.add(match.group(1)!); + var userHex = match.group(1)!; + + var nprofile = + NprofileHelper().mapToBech32({'pubkey': userHex, 'relays': []}); + return 'nostr:$nprofile '; + }); + + output = output.replaceAllMapped(RegExp(r'\(__.*?\)'), (match) { + return ''; + }); + + var content = output; + + List tags = []; + + if (state.replyToNote != null) { + final replyIsReplyToRoot = state.replyToNote!.getRootReply; + if (replyIsReplyToRoot != null) { + final tag = NostrTag( + type: "e", + value: replyIsReplyToRoot.value, + recommended_relay: "", + marker: "root", + ); + tags.add(tag); + } else { + // is reply to root + final tag = NostrTag( + type: "e", + value: state.replyToNote!.id, + recommended_relay: "", + marker: "root", + ); + tags.add(tag); + final tagPubkey = NostrTag( + type: "p", + value: state.replyToNote!.pubkey, + recommended_relay: "", + marker: "root", + ); + tags.add(tagPubkey); + } + + // add previous tweet tags + for (NostrTag tag in state.replyToNote!.tags) { + if (tag.type == "e") { + if (tag.marker == "root" || tag.marker == "reply") { + continue; + } + if (!(tags.map((e) => e.value).contains(tag.value))) { + tags.add(tag); + } + } + if (tag.type == "p") { + if (tag.marker == "root" || tag.marker == "reply") { + continue; + } + + tags.add(tag); + } + } + + if (mentionKeys.isNotEmpty) { + for (int i = 0; i < mentionKeys.length; i++) { + final pubkey = mentionKeys[i]; + final editRelayProvider = ref.watch(editRelaysProvider); + + final potentialRelays = + await editRelayProvider.getRelayHintsInbox(pubkey); + + tags.add(NostrTag( + type: "p", + value: pubkey, + recommended_relay: potentialRelays.firstOrNull?.url ?? "", + marker: "mention", + )); + } + } + + if (state.replyToNote != null) { + final tag = NostrTag( + type: "e", + value: state.replyToNote!.id, + recommended_relay: "", + marker: "reply", + ); + tags.add(tag); + + final tagPubkey = NostrTag( + type: 'p', + value: state.replyToNote!.pubkey, + recommended_relay: '', + marker: 'reply', + ); + tags.add(tagPubkey); + } + } + + // add hashtags + for (final hashtag in state.hashtagsInPost) { + tags.add( + NostrTag( + type: "t", + value: hashtag.toLowerCase().substring(1), + ), + ); + } + + // upload images + List imageUrls = []; + for (final image in state.images) { + state.uploadTasks.add(ref.watch(fileUploadProvider).uploadImage(image)); + } + + //todo!: err handling + await Future.wait(state.uploadTasks).then((urls) { + imageUrls = urls; + }); + + // add image urls to content + content += "\n"; + for (var url in imageUrls) { + content += " $url"; + } + + final notesP = ref.read(getNotesProvider); + + final signerP = ref.read(eventSignerProvider); + if (signerP == null) { + state = state.copyWith(isSubmitting: false); + return Future.error('no signer'); + } + final pubkey = signerP.getPublicKey(); + + final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + try { + await notesP.broadcastNote(NostrNote( + id: '', + pubkey: pubkey, + created_at: now, + kind: 1, + content: content, + sig: '', + tags: tags, + )); + } catch (e) { + state = state.copyWith( + isSubmitting: false, isError: true, errorText: e.toString()); + return Future.error('Error broadcasting note: $e'); + } + + state = state.copyWith( + isSubmitting: false, + ); + return; + } + + @override + WritePostState build() { + return WritePostState(markupText: ""); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6b00bba..af53d9d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -358,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 + url: "https://pub.dev" + source: hosted + version: "2.7.0" cupertino_icons: dependency: "direct main" description: @@ -430,6 +438,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + elliptic: + dependency: transitive + description: + name: elliptic + sha256: "0c303d810603953a65dc39c4c542fb7538defd9e212403c54c266140819523b6" + url: "https://pub.dev" + source: hosted + version: "0.3.11" encrypt: dependency: "direct main" description: @@ -764,14 +780,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - isar: - dependency: transitive - description: - name: isar - sha256: ebf74d87c400bd9f7da14acb31932b50c2407edbbd40930da3a6c2a8143f85a8 - url: "https://pub.dev" - source: hosted - version: "4.0.0-dev.14" js: dependency: transitive description: @@ -914,21 +922,21 @@ packages: path: "../ndk/packages/ndk" relative: true source: path - version: "0.2.0-dev005" + version: "0.2.4" ndk_amber: dependency: "direct main" description: path: "../ndk/packages/amber" relative: true source: path - version: "0.2.0-dev002" + version: "0.2.0" ndk_rust_verifier: dependency: "direct main" description: path: "../ndk/packages/rust_verifier" relative: true source: path - version: "0.2.0-dev002" + version: "0.2.0" node_preamble: dependency: transitive description: @@ -1654,6 +1662,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + web_socket_client: + dependency: transitive + description: + name: web_socket_client + sha256: "0ec5230852349191188c013112e4d2be03e3fc83dbe80139ead9bf3a136e53b5" + url: "https://pub.dev" + source: hosted + version: "0.1.5" webdriver: dependency: transitive description: