mirror of
https://github.com/vishalxl/nostr_console.git
synced 2025-06-11 09:21:18 +02:00
.. by correctly placing empty channels depending on their creation date. improved kind 0,3 fetching. improved display around channel menu and printNotifications.
1408 lines
47 KiB
Dart
1408 lines
47 KiB
Dart
import 'dart:io';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'package:bip340/bip340.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:nostr_console/tree_ds.dart';
|
|
import 'package:translator/translator.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:nostr_console/settings.dart';
|
|
import "dart:typed_data";
|
|
import 'dart:convert' as convert;
|
|
import "package:pointycastle/export.dart";
|
|
import 'package:kepler/kepler.dart';
|
|
|
|
|
|
String getStrInColor(String s, String commentColor) => stdout.supportsAnsiEscapes ?"$commentColor$s$gColorEndMarker":s;
|
|
void printInColor(String s, String commentColor) => stdout.supportsAnsiEscapes ?stdout.write("$commentColor$s$gColorEndMarker"):stdout.write(s);
|
|
void printWarning(String s) => stdout.supportsAnsiEscapes ?stdout.write("$gWarningColor$s$gColorEndMarker\n"):stdout.write("$s\n");
|
|
|
|
|
|
// translate
|
|
GoogleTranslator? translator; // initialized in main when argument given
|
|
|
|
const int gNumTranslateDays = 2;// translate for this number of days
|
|
bool gTranslate = false; // translate flag
|
|
|
|
// Structure to store kind 0 event meta data, and kind 3 meta data for each user. Will have info from latest
|
|
// kind 0 event and/or kind 3 event, both with their own time stamps.
|
|
class UserNameInfo {
|
|
int? createdAt;
|
|
String? name, about, picture;
|
|
int? createdAtKind3;
|
|
Event ?latestContactEvent;
|
|
UserNameInfo(this.createdAt, this.name, this.about, this.picture, this.latestContactEvent, [this.createdAtKind3 = null]);
|
|
}
|
|
|
|
/*
|
|
* global user names from kind 0 events, mapped from public key to a 3 element array of [name, about, picture]
|
|
* JSON object {name: <username>, about: <string>, picture: <url, string>}
|
|
* only has info from latest kind 0 event
|
|
*/
|
|
Map<String, UserNameInfo> gKindONames = {};
|
|
|
|
// global reactions entry. Map of form <if of event reacted to, List of Reactors>
|
|
// reach Reactor is a list of 2-elements ( first is public id of reactor event, second is comment)
|
|
Map< String, List<List<String>> > gReactions = {};
|
|
|
|
// global contact list of each user, including of the logged in user.
|
|
// maps from pubkey of a user, to the latest contact list of that user, which is the latest kind 3 message
|
|
// is updated as kind 3 events are received
|
|
Map< String, List<Contact>> gContactLists = {};
|
|
|
|
class EventData {
|
|
String id;
|
|
String pubkey;
|
|
int createdAt;
|
|
int kind;
|
|
String content;
|
|
List<String> eTags;// e tags
|
|
List<String> pTags;// list of p tags for kind:1
|
|
List<List<String>> tags;
|
|
bool isNotification; // whether its to be highlighted using highlight color
|
|
String evaluatedContent; // content which has mentions expanded, and which has been translated
|
|
Set<String> newLikes; // used for notifications, are colored as notifications and then reset
|
|
|
|
List<Contact> contactList = []; // used for kind:3 events, which is contact list event
|
|
|
|
bool isHidden; // hidden by sending a reaction kind 7 event to this event, by the logged in user
|
|
bool isDeleted; // deleted by kind 5 event
|
|
|
|
// returns the immediate kind 1 parent
|
|
String getParent(Map<String, Tree> allEventsMap) {
|
|
if( eTags.isNotEmpty) {
|
|
for( int i = eTags.length - 1; i >= 0; i--) {
|
|
String eventId = eTags[i];
|
|
if( allEventsMap[eventId]?.event.eventData.kind == 1) {
|
|
String? parentId = allEventsMap[eventId]?.event.eventData.id;
|
|
if( parentId != null) {
|
|
return parentId;
|
|
}
|
|
} else {
|
|
// if first e tag ( from end, which is the immediate parent) does not exist in the store, then return that eventID still.
|
|
// Child comment would get a dummy parent, and called could then fetch that event
|
|
return eventId;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
EventData(this.id, this.pubkey, this.createdAt, this.kind, this.content,
|
|
this.eTags, this.pTags, this.contactList,this.tags, this.newLikes,
|
|
{
|
|
this.isNotification = false, this.evaluatedContent = "", this.isHidden = false, this.isDeleted = false
|
|
});
|
|
|
|
factory EventData.fromJson(dynamic json) {
|
|
|
|
List<Contact> contactList = [];
|
|
|
|
List<String> eTagsRead = [];
|
|
List<String> pTagsRead = [];
|
|
List<List<String>> tagsRead = [];
|
|
|
|
var jsonTags = json['tags'];
|
|
var numTags = jsonTags.length;
|
|
|
|
// NIP 02: if the event is a contact list type, then populate contactList
|
|
if(json['kind'] == 3) {
|
|
for( int i = 0; i < numTags; i++) {
|
|
var tag = jsonTags[i];
|
|
if( tag.length < 2) {
|
|
if( gDebug > 0) print("In event fromjson: invalid p tag of size 1");
|
|
continue;
|
|
}
|
|
|
|
String server = defaultServerUrl;
|
|
if( tag.length >=3 ) {
|
|
server = tag[2].toString();
|
|
if( server == 'wss://nostr.rocks' || server == "wss://nostr.bitcoiner.social") {
|
|
server = defaultServerUrl;
|
|
}
|
|
}
|
|
|
|
if( tag[0] == "p" && tag[1].length == 64) {
|
|
Contact c = Contact(tag[1] as String, server);
|
|
contactList.add(c);
|
|
}
|
|
}
|
|
} else {
|
|
int eKind = json['kind'];
|
|
if ( eKind == 1 || eKind == 7 || eKind == 42 || eKind == 5 || eKind == 4 || eKind == 140 || eKind == 141 || eKind == 142) {
|
|
for( int i = 0; i < numTags; i++) {
|
|
var tag = jsonTags[i];
|
|
|
|
if( tag.isEmpty) {
|
|
continue;
|
|
}
|
|
if( tag[0] == "e") {
|
|
eTagsRead.add(tag[1]);
|
|
} else {
|
|
if( tag[0] == "p") {
|
|
pTagsRead.add(tag[1]);
|
|
}
|
|
}
|
|
List<String> t = [];
|
|
t.add(tag[0]);
|
|
t.add(tag[1]);
|
|
tagsRead.add(t);
|
|
|
|
// TODO add other tags
|
|
}
|
|
}
|
|
}
|
|
|
|
if( gDebug > 0 && json['id'] == gCheckEventId) {
|
|
print("\n----------------------------------------Creating EventData with content: ${json['content']}");
|
|
print("In Event fromJson: got message: $gCheckEventId");
|
|
}
|
|
|
|
return EventData(json['id'] as String, json['pubkey'] as String,
|
|
json['created_at'] as int, json['kind'] as int,
|
|
json['content'].trim() as String,
|
|
eTagsRead, pTagsRead,
|
|
contactList, tagsRead,
|
|
{});
|
|
}
|
|
|
|
String expandMentions(String content) {
|
|
if( id == gCheckEventId) {
|
|
printInColor("in expandMentions: decoding ee810ea73072af056cceaa6d051b4fcce60739247f7bcc752e72fa5defb64f09\n", redColor);
|
|
}
|
|
|
|
if( tags.isEmpty) {
|
|
return content;
|
|
}
|
|
|
|
// just check if there is any square bracket in comment, if not we return
|
|
String squareBracketStart = "[", squareBracketEnd = "]";
|
|
if( !content.contains(squareBracketStart) || !content.contains(squareBracketEnd) ) {
|
|
return content;
|
|
}
|
|
|
|
// replace the patterns
|
|
List<String> placeHolders = ["#[0]", "#[1]", "#[2]", "#[3]", "#[4]", "#[5]", "#[6]", "#[7]" ];
|
|
for(int i = 0; i < placeHolders.length && i < tags.length; i++) {
|
|
int index = -1;
|
|
Pattern p = placeHolders[i];
|
|
if( (index = content.indexOf(p)) != -1 ) {
|
|
if( tags[i].length >= 2) {
|
|
String author = getAuthorName(tags[i][1]);
|
|
content = "${content.substring(0, index)}@$author${content.substring(index + 4)}";
|
|
}
|
|
}
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// is called only once for each event received ( or read from file)
|
|
void translateAndExpandMentions(List<DirectMessageRoom> directRooms, Map<String, Tree> tempChildEventsMap) {
|
|
if( id == gCheckEventId) {
|
|
printInColor("in translateAndExpandMensitons: decoding ee810ea73072af056cceaa6d051b4fcce60739247f7bcc752e72fa5defb64f09\n", redColor);
|
|
}
|
|
|
|
if (content == "" || evaluatedContent != "") {
|
|
if( id == gCheckEventId) {
|
|
printInColor("in translateAndExpandMensitons: returning \n", redColor);
|
|
}
|
|
return;
|
|
}
|
|
|
|
|
|
switch(kind) {
|
|
case 1:
|
|
case 42:
|
|
evaluatedContent = expandMentions(content);
|
|
if( translator != null && gTranslate && !evaluatedContent.isEnglish()) {
|
|
if( gDebug > 0) print("found that this comment is non-English: $evaluatedContent");
|
|
|
|
// only translate for latest events
|
|
if( DateTime.fromMillisecondsSinceEpoch(createdAt *1000).compareTo( DateTime.now().subtract(Duration(days:gNumTranslateDays)) ) > 0 ) {
|
|
if( gDebug > 0) print("Sending google request: translating $content");
|
|
if( translator != null) {
|
|
try {
|
|
translator?.translate(content, to: 'en')
|
|
.then( (result) => { evaluatedContent = "$evaluatedContent\n\nTranslation: ${result.toString()}" , if( gDebug > 0) print("Google translate returned successfully for one call.")} )
|
|
.onError((error, stackTrace) {
|
|
if( gDebug > 0) print("Translate error = $error\n for content = $content\n");
|
|
return {} ;
|
|
}
|
|
);
|
|
} on Exception catch(err) {
|
|
if( gDebug >= 0) print("Info: Error in trying to use google translate: $err");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 4:
|
|
if( userPrivateKey == ""){ // cant process if private key not given
|
|
break;
|
|
}
|
|
//if( pubkey == userPublicKey ) break; // crashes right now otherwise
|
|
if(!isValidDirectMessage(this)) {
|
|
break;
|
|
}
|
|
|
|
if( id == gCheckEventId) {
|
|
printInColor("in translateAndExpandMensitons: gonna decrypt \n", redColor);
|
|
}
|
|
|
|
String? decrypted = decryptDirectMessage();
|
|
if( decrypted != null) {
|
|
evaluatedContent = decrypted;
|
|
evaluatedContent = expandMentions(evaluatedContent);
|
|
}
|
|
break;
|
|
|
|
|
|
} // end switch
|
|
} // end translateAndExpandMentions
|
|
|
|
|
|
// is called only once for each event received ( or read from file)
|
|
void translateAndExpand14x(List<DirectMessageRoom> directRooms, List<Channel> encryptedChannels, Map<String, Tree> tempChildEventsMap) {
|
|
if( id == gCheckEventId) {
|
|
printInColor("in translateAndExpandMensitons: decoding ee810ea73072af056cceaa6d051b4fcce60739247f7bcc752e72fa5defb64f09\n", redColor);
|
|
}
|
|
|
|
if (content == "" || evaluatedContent != "") {
|
|
if( id == gCheckEventId) {
|
|
printInColor("in translateAndExpandMensitons: returning \n", redColor);
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch(kind) {
|
|
case 142:
|
|
String? decrypted = decryptEncryptedChannelMessage(directRooms, encryptedChannels, tempChildEventsMap);
|
|
if( decrypted != null) {
|
|
evaluatedContent = decrypted;
|
|
evaluatedContent = expandMentions(evaluatedContent);
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
|
|
} // end switch
|
|
} // end translateAndExpand14x
|
|
|
|
|
|
String? decryptDirectMessage() {
|
|
int ivIndex = content.indexOf("?iv=");
|
|
var iv = content.substring( ivIndex + 4, content.length);
|
|
var enc_str = content.substring(0, ivIndex);
|
|
|
|
String userKey = userPrivateKey ;
|
|
String otherUserPubKey = "02" + pubkey;
|
|
if( pubkey == userPublicKey) { // if user themselve is the sender change public key used to decrypt
|
|
userKey = userPrivateKey;
|
|
int numPtags = 0;
|
|
tags.forEach((tag) {
|
|
if(tag[0] == "p" ) {
|
|
otherUserPubKey = "02" + tag[1];
|
|
numPtags++;
|
|
}
|
|
});
|
|
// if there are more than one p tags, we don't know who its for
|
|
if( numPtags != 1) {
|
|
if( gDebug >= 0) printInColor(" in translateAndExpand: got event $id with number of p tags != one : $numPtags . not decrypting", redColor);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
var decrypted = myPrivateDecrypt( userKey, otherUserPubKey, enc_str, iv); // use bob's privatekey and alic's publickey means bob can read message from alic
|
|
return decrypted;
|
|
}
|
|
|
|
Channel? getChannelForMessage(List<Channel>? listChannel, String messageId) {
|
|
if( listChannel == null) {
|
|
return null;
|
|
}
|
|
for(int i = 0; i < listChannel.length; i++) {
|
|
if( listChannel[i].messageIds.contains(messageId)) {
|
|
return listChannel[i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
|
|
String? decryptEncryptedChannelMessage(List<DirectMessageRoom> directRooms, List<Channel> encryptedChannels,Map<String, Tree> tempChildEventsMap) {
|
|
Channel? channel = getChannelForMessage( encryptedChannels, id);
|
|
if( channel == null) {
|
|
print("could not find channel");
|
|
return null;
|
|
}
|
|
|
|
if(!channel.participants.contains(userPublicKey)) {
|
|
return null;
|
|
}
|
|
|
|
if(!channel.participants.contains(pubkey)) {
|
|
return null;
|
|
}
|
|
|
|
if( id == "865c9352de11a3959c06fce5350c5a1b9fa0475d3234078a1bb45d152b370f0b") { // known issue
|
|
|
|
//print("\n\ngoing to decrypt b1ab66ac50f00f3c3bbc91e5b9e03fc8e79e3fdb9f6d5c9ae9777aa6ca3020a2");
|
|
//print(channel.participants);
|
|
return "";
|
|
}
|
|
|
|
//print("Going to decrypt event id: $id");
|
|
|
|
//print("In decryptEncryptedChannelMessage: for event of kind 142 with event id = $id");
|
|
int ivIndex = content.indexOf("?iv=");
|
|
var iv = content.substring( ivIndex + 4, content.length);
|
|
var enc_str = content.substring(0, ivIndex);
|
|
|
|
String channelId = getChannelIdForMessage();
|
|
//print("In decryptEncryptedChannelMessage: got channel id $channelId");
|
|
List<String> keys = [];
|
|
keys = getEncryptedChannelKeys(directRooms, tempChildEventsMap, channelId);
|
|
|
|
if( keys.length != 2) {
|
|
printInColor("Could not get keys for event id: $id and channelId: $channelId", redColor);
|
|
return "";
|
|
}
|
|
|
|
//print("\nevent id: $id");
|
|
//print(keys);
|
|
|
|
String priKey = keys[0];
|
|
String pubKey = "02" + keys[1];
|
|
|
|
var decrypted = myPrivateDecrypt( priKey, pubKey, enc_str, iv); // use bob's privatekey and alic's publickey means bob can read message from alic
|
|
return decrypted;
|
|
}
|
|
|
|
// only applicable for kind 42/142 event; returns the channel 40/140 id of which the event is part of
|
|
String getChannelIdForMessage() {
|
|
if( kind != 42 && kind != 142 && kind!=141) {
|
|
return "";
|
|
}
|
|
//print("in getChannelIdForMessage tag length = ${tags.length}");
|
|
// get first e tag, which should be the channel of which this is part of
|
|
for( int i = 0; i < tags.length; i++) {
|
|
List tag = tags[i];
|
|
if( tag[0] == 'e') {
|
|
return tag[1];
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// prints event data in the format that allows it to be shown in tree form by the Tree class
|
|
void printEventData(int depth, bool topPost) {
|
|
if( !(kind == 1 || kind == 4 || kind == 42)) {
|
|
return; // only print kind 1 and 42 and 4
|
|
}
|
|
|
|
int n = 4;
|
|
String maxN(String v) => v.length > n? v.substring(0,n) : v.substring(0, v.length);
|
|
|
|
String name = getAuthorName(pubkey);
|
|
String strDate = getPrintableDate(createdAt);
|
|
String tempEvaluatedContent = evaluatedContent;
|
|
String tempContent = content;
|
|
|
|
if( isHidden) {
|
|
name = "<hidden>";
|
|
strDate = "<hidden>";
|
|
tempEvaluatedContent = tempContent = "<You have hidden this post>";
|
|
}
|
|
|
|
// delete supercedes hidden
|
|
if( isDeleted) {
|
|
name = "<deleted>";
|
|
strDate = "<deleted>";
|
|
tempEvaluatedContent = tempContent = content; // content would be changed so show that
|
|
}
|
|
|
|
if( createdAt == 0) {
|
|
print("debug: createdAt == 0 for event $id $content");
|
|
}
|
|
|
|
String commentColor = "";
|
|
if( isNotification) {
|
|
commentColor = gNotificationColor;
|
|
isNotification = false;
|
|
} else {
|
|
commentColor = gCommentColor;
|
|
}
|
|
|
|
int tempEffectiveLen = name.length < gNameLengthInPost? name.length: gNameLengthInPost;
|
|
name = name.substring(0,tempEffectiveLen);
|
|
|
|
int effectiveNameFieldLen = gNameLengthInPost + 3; // get this before name is mangled by color
|
|
String nameColor = getNameColor(pubkey);
|
|
|
|
// pad name to left
|
|
name = name.padLeft(gNameLengthInPost);
|
|
name = name.substring(0, gNameLengthInPost);
|
|
name = getStrInColor(name, nameColor);
|
|
|
|
String strToPrint = "";
|
|
if(!topPost) {
|
|
strToPrint += "\n";
|
|
strToPrint += getDepthSpaces(depth);
|
|
strToPrint += " "; // in place of block for top posts
|
|
} else {
|
|
strToPrint += getDepthSpaces(depth);
|
|
strToPrint += "█";
|
|
}
|
|
|
|
strToPrint += "${name}: ";
|
|
const int typicalxLen = "|id: 82b5 , 12:04 AM Sep 19".length + 5; // not sure where 5 comes from
|
|
String idDateLikes = " |id: ${maxN(id)} , $strDate ${getReactionStr(depth)}" ;
|
|
idDateLikes = idDateLikes.padRight(typicalxLen);
|
|
|
|
String temp = tempEvaluatedContent==""?tempContent: tempEvaluatedContent;
|
|
String contentShifted = makeParagraphAtDepth( temp, gSpacesPerDepth * depth + effectiveNameFieldLen);
|
|
|
|
int maxLineLen = gTextWidth - gSpacesPerDepth * depth - effectiveNameFieldLen ;
|
|
int lastLineLen = contentShifted.length;
|
|
int i = 0;
|
|
|
|
contentShifted = contentShifted.trim();
|
|
|
|
// find the effective length of the last line of the content
|
|
for(i = contentShifted.length - 1; i >= 0; i-- ) {
|
|
if( contentShifted[i] == "\n") {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if( i >= 0 && contentShifted[i] == "\n") {
|
|
lastLineLen = contentShifted.length - i;
|
|
}
|
|
|
|
// effective len of last line is used to calcluate where the idDateLikes str is affixed at the end
|
|
int effectiveLastLineLen = lastLineLen - gSpacesPerDepth * depth - effectiveNameFieldLen - gNumLeftMarginSpaces;
|
|
if( contentShifted.length <= maxLineLen )
|
|
effectiveLastLineLen = contentShifted.length;
|
|
|
|
// now actually find where the likesDates string goes
|
|
if( (gSpacesPerDepth * depth + effectiveNameFieldLen + effectiveLastLineLen + idDateLikes.length ) <= gTextWidth) {
|
|
idDateLikes = idDateLikes.padLeft((gTextWidth ) - (gSpacesPerDepth * depth + effectiveNameFieldLen + effectiveLastLineLen));
|
|
} else {
|
|
idDateLikes = "\n" + idDateLikes.padLeft(gNumLeftMarginSpaces + gTextWidth);
|
|
}
|
|
|
|
// print content and the dateslikes string
|
|
strToPrint += getStrInColor(contentShifted + idDateLikes + "\n", commentColor);
|
|
stdout.write(strToPrint);
|
|
}
|
|
|
|
String getAsLine({int len = 20}) {
|
|
String contentToPrint = evaluatedContent.isEmpty? content: evaluatedContent;
|
|
if( len == 0 || len > contentToPrint.length) {
|
|
len = contentToPrint.length;
|
|
}
|
|
|
|
contentToPrint = contentToPrint.padLeft(len);
|
|
contentToPrint = contentToPrint.replaceAll("\n", " ");
|
|
contentToPrint = contentToPrint.replaceAll("\r", " ");
|
|
String strToPrint = '${contentToPrint.substring(0, len)}... - ${getAuthorName(pubkey)}';
|
|
|
|
int strWidth = 40;
|
|
String paddedStrToPrint = strToPrint.padLeft(strWidth);
|
|
paddedStrToPrint = paddedStrToPrint.substring(0, strWidth);
|
|
|
|
if( isNotification) {
|
|
paddedStrToPrint = "$gNotificationColor$paddedStrToPrint$gColorEndMarker";
|
|
isNotification = false;
|
|
}
|
|
return paddedStrToPrint;
|
|
}
|
|
|
|
String getStrForChannel(int depth) {
|
|
String strToPrint = "";
|
|
String name = getAuthorName(pubkey);
|
|
String strDate = getPrintableDate(createdAt);
|
|
String tempEvaluatedContent = evaluatedContent;
|
|
String tempContent = evaluatedContent.isEmpty? content: evaluatedContent;
|
|
|
|
if( isHidden) {
|
|
name = strDate = "<hidden>";
|
|
tempEvaluatedContent = tempContent = "<You have hidden this post>";
|
|
}
|
|
|
|
// delete supercedes hidden
|
|
if( isDeleted) {
|
|
name = strDate = "<deleted>";
|
|
tempEvaluatedContent = tempContent = content; // content would be changed so show that
|
|
}
|
|
|
|
if( tempEvaluatedContent=="")
|
|
tempEvaluatedContent = tempContent;
|
|
|
|
const int nameWidthDepth = 16~/gSpacesPerDepth; // how wide name will be in depth spaces
|
|
const int timeWidthDepth = 18~/gSpacesPerDepth;
|
|
int nameWidth = gSpacesPerDepth * nameWidthDepth;
|
|
|
|
// get name in color and pad it too
|
|
String nameToPrint = name.padLeft(nameWidth).substring(0, nameWidth);
|
|
nameToPrint = getStrInColor(nameToPrint, getNameColor(pubkey));
|
|
|
|
String dateToPrint = strDate.padLeft(gSpacesPerDepth * timeWidthDepth).substring(0, gSpacesPerDepth * timeWidthDepth);
|
|
|
|
// depth above + ( depth numberof spaces = 1) + (depth of time = 2) + (depth of name = 3)
|
|
int contentDepth = depth + 1 + timeWidthDepth + nameWidthDepth;
|
|
int magicNumberDepth6 = 2; // magic number for gSpacesPerDepth == 6
|
|
int finalContentDepthInSpaces = gSpacesPerDepth * contentDepth + magicNumberDepth6;
|
|
int contentPlacementColumn = finalContentDepthInSpaces + gNumLeftMarginSpaces;
|
|
|
|
String contentShifted = makeParagraphAtDepth(tempEvaluatedContent, finalContentDepthInSpaces);
|
|
|
|
Event? replyToEvent = getReplyToEvent();
|
|
String strReplyTo = "";
|
|
if( replyToEvent != null) {
|
|
//print("in getStrForChannel: got replyTo id = ${replyToEvent.eventData.id}");
|
|
if( replyToEvent.eventData.kind == 1 || replyToEvent.eventData.kind == 42) { // make sure its a kind 1 or 40 message
|
|
if( replyToEvent.eventData.id != id) { // basic self test
|
|
strReplyTo = 'In reply to:"${replyToEvent.eventData.content}"';
|
|
strReplyTo = makeParagraphAtDepth(strReplyTo, finalContentDepthInSpaces + 6); // one extra for content
|
|
|
|
// add reply to string to end of the content. How it will show:
|
|
contentShifted += ( "\n" + getNumSpaces( contentPlacementColumn + gSpacesPerDepth) + strReplyTo);
|
|
}
|
|
}
|
|
}
|
|
|
|
if( isNotification) {
|
|
strToPrint = "$gNotificationColor${getDepthSpaces(depth)} $dateToPrint $nameToPrint: $gNotificationColor" + contentShifted + gColorEndMarker;
|
|
isNotification = false;
|
|
} else {
|
|
strToPrint = "${getDepthSpaces(depth)} $dateToPrint $nameToPrint: " + contentShifted;
|
|
}
|
|
return strToPrint;
|
|
}
|
|
|
|
// looks up global map of reactions, if this event has any reactions, and then prints the reactions
|
|
// in appropriate color( in case one is a notification, which is stored in member variable)
|
|
String getReactionStr(int depth) {
|
|
String reactorNames = "";
|
|
|
|
if( isHidden || isDeleted) {
|
|
return "";
|
|
}
|
|
|
|
if( gReactions.containsKey(id)) {
|
|
reactorNames = "Likes: ";
|
|
int numReactions = gReactions[id]?.length??0;
|
|
List<List<String>> reactors = gReactions[id]??[];
|
|
bool firstEntry = true;
|
|
for( int i = 0; i <numReactions; i++) {
|
|
|
|
String comma = (firstEntry)?"":", ";
|
|
|
|
String reactorId = reactors[i][0];
|
|
if( newLikes.contains(reactorId) && reactors[i][1] == "+") {
|
|
// this is a notifications, print it and then later empty newLikes
|
|
reactorNames += comma + gNotificationColor + getAuthorName(reactorId) + gColorEndMarker;
|
|
firstEntry = false;
|
|
} else {
|
|
// this is normal printing of the reaction. only print for + for now
|
|
if( reactors[i][1] == "+")
|
|
reactorNames += comma + getAuthorName(reactorId);
|
|
firstEntry = false;
|
|
}
|
|
} // end for
|
|
newLikes.clear();
|
|
reactorNames += "";
|
|
}
|
|
return reactorNames;
|
|
}
|
|
|
|
// returns the last e tag as reply to event for kind 42 events
|
|
Event? getReplyToEvent() {
|
|
for(int i = tags.length - 1; i >= 0; i--) {
|
|
List tag = tags[i];
|
|
if( tag[0] == 'e') {
|
|
String replyToEventId = tag[1];
|
|
Event? eventInReplyTo = (gStore?.allChildEventsMap[replyToEventId]?.event)??null;
|
|
if( eventInReplyTo != null) {
|
|
if ( [1,42].contains( eventInReplyTo.eventData.kind)) {
|
|
return eventInReplyTo;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
} // end getReplyToEvent()
|
|
}
|
|
|
|
// This is mostly a placeholder for EventData. TODO combine both?
|
|
class Event {
|
|
String event;
|
|
String id;
|
|
EventData eventData;
|
|
String originalJson;
|
|
List<String> seenOnRelays;
|
|
bool readFromFile;
|
|
|
|
Event(this.event, this.id, this.eventData, this.seenOnRelays, this.originalJson, [this.readFromFile = false]);
|
|
|
|
@override
|
|
bool operator ==( other) {
|
|
return (other is Event) && eventData.id == other.eventData.id;
|
|
}
|
|
|
|
factory Event.fromJson(String d, String relay, [bool fromFile = false]) {
|
|
try {
|
|
dynamic json = jsonDecode(d);
|
|
if( json.length < 3) {
|
|
String e = "";
|
|
e = json.length > 1? json[0]: "";
|
|
if( gDebug> 0) {
|
|
print("Could not create event. returning dummy event. json.length = ${json.length} string d= $d $e");
|
|
}
|
|
return Event(e,"",EventData("non","", 0, 0, "", [], [], [], [[]], {}), [relay], "[json]", fromFile);
|
|
}
|
|
EventData newEventData = EventData.fromJson(json[2]);
|
|
if( !fromFile)
|
|
newEventData.isNotification = true;
|
|
return Event(json[0] as String, json[1] as String, newEventData, [relay], d, fromFile );
|
|
} on Exception catch(e) {
|
|
if( gDebug> 0) {
|
|
print("Could not create event. returning dummy event. $e");
|
|
print("problem str: $d\n");
|
|
}
|
|
return Event("","",EventData("non","", 0, 0, "", [], [], [], [[]], {}), [relay], "[json]", fromFile);
|
|
}
|
|
}
|
|
|
|
void printEvent(int depth, bool topPost) {
|
|
eventData.printEventData(depth, topPost);
|
|
//stdout.write("\n$originalJson --------------------------------\n\n");
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return '$eventData Seen on: ${seenOnRelays[0]}\n';
|
|
}
|
|
}
|
|
|
|
void addToHistogram(Map<String, int> histogram, List<String> pTags) {
|
|
Set tempPtags = {};
|
|
pTags.retainWhere((x) => tempPtags.add(x));
|
|
|
|
for(int i = 0; i < pTags.length; i++ ) {
|
|
String pTag = pTags[i];
|
|
if( histogram.containsKey(pTag)) {
|
|
int? val = histogram[pTag];
|
|
if( val != null) {
|
|
histogram[pTag] = ++val;
|
|
} else {
|
|
}
|
|
} else {
|
|
histogram[pTag] = 1;
|
|
}
|
|
}
|
|
//return histogram;
|
|
}
|
|
|
|
class HistogramEntry {
|
|
String str;
|
|
int count;
|
|
HistogramEntry(this.str, this.count);
|
|
static int histogramSorter(HistogramEntry a, HistogramEntry b) {
|
|
if( a.count < b.count ) {
|
|
return 1;
|
|
} if( a.count == b.count ) {
|
|
return 0;
|
|
} else {
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// return the numMostFrequent number of most frequent p tags ( user pubkeys) in the given events
|
|
List<String> getpTags(Set<Event> events, int numMostFrequent) {
|
|
List<HistogramEntry> listHistogram = [];
|
|
Map<String, int> histogramMap = {};
|
|
for(var event in events) {
|
|
addToHistogram(histogramMap, event.eventData.pTags);
|
|
}
|
|
|
|
histogramMap.forEach((key, value) {listHistogram.add(HistogramEntry(key, value));/* print("added to list of histogramEntry $key $value"); */});
|
|
listHistogram.sort(HistogramEntry.histogramSorter);
|
|
List<String> ptags = [];
|
|
for( int i = 0; i < listHistogram.length && i < numMostFrequent; i++ ) {
|
|
ptags.add(listHistogram[i].str);
|
|
}
|
|
|
|
return ptags;
|
|
}
|
|
|
|
// From the list of events provided, lookup the lastst contact information for the given user/pubkey
|
|
Event? getContactEvent(String pubkey) {
|
|
|
|
// get the latest kind 3 event for the user, which lists his 'follows' list
|
|
if( gKindONames.containsKey(pubkey)) {
|
|
Event? e = (gKindONames[pubkey]?.latestContactEvent)??null;
|
|
return e;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// for the user userPubkey, returns the relay of its contact contactPubkey
|
|
String getRelayOfUser(String userPubkey, String contactPubkey) {
|
|
|
|
if(gDebug > 0) print("In getRelayOfUser: Searching relay for contact $contactPubkey" );
|
|
|
|
String relay = "";
|
|
if( userPubkey == "" || contactPubkey == "") {
|
|
return "";
|
|
}
|
|
|
|
if( gContactLists.containsKey(userPubkey)) {
|
|
List<Contact>? contacts = gContactLists[userPubkey];
|
|
if( contacts != null) {
|
|
for( int i = 0; i < contacts.length; i++) {
|
|
if( contacts[i].id == contactPubkey) {
|
|
relay = contacts[i].relay;
|
|
return relay;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// if not found return empty string
|
|
return relay;
|
|
}
|
|
|
|
// If given event is kind 0 event, then populates gKindONames with that info
|
|
// returns true if entry was created or modified, false otherwise
|
|
bool processKind0Event(Event e) {
|
|
if( e.eventData.kind != 0) {
|
|
return false;
|
|
}
|
|
|
|
String content = e.eventData.content;
|
|
if( content.isEmpty) {
|
|
return false;
|
|
}
|
|
|
|
String name = "";
|
|
String about = "";
|
|
String picture = "";
|
|
|
|
try {
|
|
dynamic json = jsonDecode(content);
|
|
name = json["name"];
|
|
about = json["about"];
|
|
picture = json["picture"];
|
|
} catch(ex) {
|
|
//if( gDebug != 0) print("Warning: In processKind0Event: caught exception for content: ${e.eventData.content}");
|
|
if( name.isEmpty) {
|
|
//return false;
|
|
}
|
|
}
|
|
|
|
bool newEntry = false, entryModified = false;
|
|
if( !gKindONames.containsKey(e.eventData.pubkey)) {
|
|
gKindONames[e.eventData.pubkey] = UserNameInfo(e.eventData.createdAt, name, about, picture, null);
|
|
|
|
newEntry = true;;
|
|
} else {
|
|
int oldTime = gKindONames[e.eventData.pubkey]?.createdAt??0;
|
|
if( oldTime < e.eventData.createdAt) {
|
|
Event? oldContactEvent = gKindONames[e.eventData.pubkey]?.latestContactEvent;
|
|
gKindONames[e.eventData.pubkey] = UserNameInfo(e.eventData.createdAt, name, about, picture, oldContactEvent);
|
|
entryModified = true;;
|
|
}
|
|
}
|
|
|
|
if(gDebug > 0) {
|
|
print("At end of processKind0Events: for name = $name ${newEntry? "added entry": ( entryModified?"modified entry": "No change done")} ");
|
|
}
|
|
return newEntry || entryModified;
|
|
}
|
|
|
|
// If given event is kind 3 event, then populates gKindONames with contact info
|
|
// returns true if entry was created or modified, false otherwise
|
|
bool processKind3Event(Event newContactEvent) {
|
|
if( newContactEvent.eventData.kind != 3) {
|
|
return false;
|
|
}
|
|
|
|
bool newEntry = false, entryModified = false;
|
|
if( !gKindONames.containsKey(newContactEvent.eventData.pubkey)) {
|
|
gKindONames[newContactEvent.eventData.pubkey] = UserNameInfo(null, null, null, null, newContactEvent, newContactEvent.eventData.createdAt);
|
|
newEntry = true;;
|
|
} else {
|
|
// if entry already exists, then check its old time and update only if we have a newer entry now
|
|
int oldTime = gKindONames[newContactEvent.eventData.pubkey]?.createdAtKind3??0;
|
|
if( oldTime < newContactEvent.eventData.createdAt) {
|
|
int? createdAt = gKindONames[newContactEvent.eventData.pubkey]?.createdAt??null;
|
|
String? name = gKindONames[newContactEvent.eventData.pubkey]?.name, about = gKindONames[newContactEvent.eventData.pubkey]?.about, picture = gKindONames[newContactEvent.eventData.pubkey]?.picture;
|
|
|
|
gKindONames[newContactEvent.eventData.pubkey] = UserNameInfo(createdAt, name, about, picture, newContactEvent, newContactEvent.eventData.createdAt );
|
|
entryModified = true;;
|
|
}
|
|
}
|
|
|
|
if(gDebug > 0) {
|
|
print("At end of processKind3Events: ${newEntry? "added entry": ( entryModified?"modified entry": "No change done")} ");
|
|
}
|
|
return newEntry || entryModified;
|
|
}
|
|
|
|
// returns name by looking up global list gKindONames, which is populated by kind 0 events
|
|
String getAuthorName(String pubkey, [int len = 3]) {
|
|
String max3(String v) => v.length > len? v.substring(0,len) : v.substring(0, v.length);
|
|
String name = "";
|
|
if( gKindONames[pubkey]?.name == null || gKindONames[pubkey]?.name?.length == 0)
|
|
name = max3(pubkey);
|
|
else
|
|
name = (gKindONames[pubkey]?.name)??max3(pubkey);
|
|
return name;
|
|
}
|
|
|
|
// returns full public key(s) for the given username( which can be first few letters of pubkey, or the user name)
|
|
Set<String> getPublicKeyFromName(String inquiredName) {
|
|
if( inquiredName.length < 2) {
|
|
return {};
|
|
}
|
|
Set<String> pubkeys = {};
|
|
gKindONames.forEach((pubkey, userInfo) {
|
|
// check both the user name, and the pubkey to search for the user
|
|
// check username
|
|
if( userInfo.name != null) {
|
|
int minNameLen = min( inquiredName.length, (userInfo.name?.length)??0);
|
|
if( inquiredName.toLowerCase() == userInfo.name?.substring(0, minNameLen).toLowerCase()) {
|
|
pubkeys.add(pubkey);
|
|
}
|
|
}
|
|
|
|
// check public key
|
|
if( inquiredName.length <= pubkey.length) {
|
|
if( pubkey.substring(0, inquiredName.length) == inquiredName) {
|
|
pubkeys.add(pubkey);
|
|
}
|
|
}
|
|
});
|
|
|
|
return pubkeys;
|
|
}
|
|
|
|
// returns the seconds since eponch N days ago
|
|
int getSecondsDaysAgo( int N) {
|
|
return DateTime.now().subtract(Duration(days: N)).millisecondsSinceEpoch ~/ 1000;
|
|
}
|
|
|
|
void printUnderlined(String x) => { stdout.write("$x\n${getNumDashes(x.length)}\n")};
|
|
|
|
void printDepth(int d) {
|
|
for( int i = 0; i < gSpacesPerDepth * d + gNumLeftMarginSpaces; i++) {
|
|
stdout.write(" ");
|
|
}
|
|
}
|
|
|
|
void printCenteredHeadline(displayName) {
|
|
int numDashes = 10; // num of dashes on each side
|
|
int startText = gNumLeftMarginSpaces + ( gTextWidth - (displayName.length + 2 * numDashes)) ~/ 2;
|
|
if( startText < 0)
|
|
startText = 0;
|
|
|
|
String str = getNumSpaces(startText) + getNumDashes(numDashes) + displayName + getNumDashes(numDashes);
|
|
print(str);
|
|
|
|
}
|
|
|
|
String getDepthSpaces(int d) {
|
|
String str = "";
|
|
for( int i = 0; i < gSpacesPerDepth * d + gNumLeftMarginSpaces; i++) {
|
|
str += " ";
|
|
}
|
|
return str;
|
|
}
|
|
|
|
String getNumSpaces(int num) {
|
|
String s = "";
|
|
for( int i = 0; i < num; i++) {
|
|
s += " ";
|
|
}
|
|
return s;
|
|
}
|
|
|
|
String getNumDashes(int num, [String dashType = "-"]) {
|
|
String s = "";
|
|
for( int i = 0; i < num; i++) {
|
|
s += dashType;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// make a paragraph of s that starts at numSpaces ( from screen left), and does not extend beyond gTextWidth+gNumLeftMarginSpaces. break it, or add
|
|
// a newline if it goes beyond gTextWidth + gNumLeftMarginSpaces
|
|
String makeParagraphAtDepth(String s, int depthInSpaces) {
|
|
|
|
String newString = "";
|
|
String spacesString = getNumSpaces(depthInSpaces + gNumLeftMarginSpaces);
|
|
|
|
|
|
int lenPerLine = gTextWidth - depthInSpaces;
|
|
//print("In makeParagraphAtDepth: gNumLeftMarginSpaces = $gNumLeftMarginSpaces depthInSPaces = $depthInSpaces LenPerLine = $lenPerLine gTextWidth = $gTextWidth ");
|
|
for(int startIndex = 0; startIndex < s.length; ) {
|
|
List listCulledLine = getLineWithMaxLen(s, startIndex, lenPerLine, spacesString);
|
|
|
|
String line = listCulledLine[0];
|
|
int lenReturned = listCulledLine[1] as int;
|
|
|
|
//print("In makeParagraphAtDepth: line len = ${line.length} lenReturned = $lenReturned");
|
|
|
|
if( line.length == 0 || lenReturned == 0) break;
|
|
|
|
newString += line;
|
|
startIndex += lenReturned;
|
|
}
|
|
|
|
return newString;
|
|
}
|
|
|
|
// returns from string[startIndex:] the first len number of chars. no newline is added.
|
|
List getLineWithMaxLen(String s, int startIndex, int lenPerLine, String spacesString) {
|
|
|
|
if( startIndex >= s.length)
|
|
return ["", 0];
|
|
|
|
String line = "";
|
|
|
|
int firstLineLen = -1;
|
|
// if length required is greater than the length of string remaing, return whatever remains
|
|
int numCharsInLine = 0;
|
|
|
|
int i = startIndex;
|
|
for(; i < startIndex + lenPerLine && i < s.length; i++) {
|
|
line += s[i];
|
|
numCharsInLine ++;
|
|
|
|
if( s[i] == "\n") {
|
|
i++;
|
|
numCharsInLine = 0;
|
|
line += spacesString;
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
//print(" ${numCharsInLine > lenPerLine} || ${( (numCharsInLine == lenPerLine) && (s.length > startIndex + numCharsInLine))}");
|
|
if( numCharsInLine > lenPerLine || ( (numCharsInLine == lenPerLine) && (s.length > startIndex + numCharsInLine) )) {
|
|
bool lineBroken = false;
|
|
|
|
// line is broken only if the returned line is the longest it can be, and
|
|
// if its length is greater than the gMaxLenBrokenWord constant
|
|
if( line.length >= lenPerLine && line.length > gMaxLenUnbrokenWord ) {
|
|
//print(" line ending seems to be cutting words");
|
|
int i = line.length - 1;
|
|
|
|
// find a whitespace character
|
|
for( ; i > 0 && !isWordSeparater(line[i]); i--);
|
|
// for ended
|
|
|
|
if( line.length - i < gMaxLenUnbrokenWord) {
|
|
|
|
// break the line here if its not a word separator
|
|
if( isWordSeparater(line[i])) {
|
|
//print(" isWordSeparater i = $i line[i] = ${line[i]}");
|
|
firstLineLen = i;
|
|
int newLineStart = i + 1;
|
|
if( line[i] != ' ')
|
|
newLineStart = i;
|
|
line = line.substring(0, i) + "\n" + spacesString + line.substring(newLineStart, line.length);
|
|
lineBroken = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
if( !lineBroken ) {
|
|
if( s.length > i ) {
|
|
line += "\n";
|
|
line += spacesString;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [line, i - startIndex];
|
|
}
|
|
|
|
bool nonEnglish(String str) {
|
|
bool result = false;
|
|
return result;
|
|
}
|
|
|
|
bool isNumeric(String s) {
|
|
return double.tryParse(s) != null;
|
|
}
|
|
|
|
bool isWordSeparater(String s) {
|
|
if( s.length != 1) {
|
|
return false;
|
|
}
|
|
return s[0] == ' ' || s[0] == '\n' || s[0] == '\r' || s[0] == '\t'
|
|
|| s[0] == ',' || s[0] == '.' || s[0] == '-' || s[0] == '('|| s[0] == ')';
|
|
}
|
|
|
|
|
|
bool isWhitespace(String s) {
|
|
if( s.length != 1) {
|
|
return false;
|
|
}
|
|
return s[0] == ' ' || s[0] == '\n' || s[0] == '\r' || s[0] == '\t';
|
|
}
|
|
|
|
extension StringX on String {
|
|
|
|
|
|
isChannelPageNumber(int max) {
|
|
|
|
int? n = int.tryParse(this);
|
|
if( n != null) {
|
|
if( n < max)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
isEnglish( ) {
|
|
// since smaller words can be smileys they should not be translated
|
|
if( length < 10)
|
|
return true;
|
|
|
|
if( !isLatinAlphabet())
|
|
return false;
|
|
|
|
if (isFrench())
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
isPortugese() {
|
|
false; // https://1000mostcommonwords.com/1000-most-common-portuguese-words/
|
|
}
|
|
|
|
bool isFrench() {
|
|
|
|
// https://www.thoughtco.com/most-common-french-words-1372759
|
|
List<String> frenchWords = ["oui", "je", "le", "un", "de", "et", "merci", "une", "ce", "pas"];
|
|
for( int i = 0; i < frenchWords.length; i++) {
|
|
if( this.toLowerCase().contains(" ${frenchWords[i]} ")) {
|
|
if( gDebug > 0) print("isFrench: Found ${this.toString()} is french");
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
isLatinAlphabet({caseSensitive = false}) {
|
|
int countLatinletters = 0;
|
|
for (int i = 0; i < length; i++) {
|
|
final target = caseSensitive ? this[i] : this[i].toLowerCase();
|
|
if ( (target.codeUnitAt(0) > 96 && target.codeUnitAt(0) < 123) || ( isNumeric(target) ) || isWhitespace(target)) {
|
|
countLatinletters++;
|
|
}
|
|
}
|
|
|
|
if( countLatinletters < ( 40.0/100 ) * length ) {
|
|
if( gDebug > 0) print("in isLatinAlphabet: latin letters: $countLatinletters and total = $length ");
|
|
return false;
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// The contact only stores id and relay of contact. The actual name is stored in a global variable/map
|
|
class Contact {
|
|
String id, relay;
|
|
Contact(this.id, this.relay);
|
|
|
|
@override
|
|
String toString() {
|
|
return 'id: $id ( ${getAuthorName(id)}) relay: $relay';
|
|
}
|
|
}
|
|
|
|
String addEscapeChars(String str) {
|
|
return str.replaceAll("\"", "\\\"");
|
|
}
|
|
|
|
String getShaId(String pubkey, int createdAt, String kind, String strTags, String content) {
|
|
String buf = '[0,"$pubkey",$createdAt,$kind,[$strTags],"$content"]';
|
|
var bufInBytes = utf8.encode(buf);
|
|
var value = sha256.convert(bufInBytes);
|
|
return value.toString();
|
|
}
|
|
|
|
// get printable date from seconds since epoch
|
|
String getPrintableDate(int createdAt) {
|
|
final df1 = DateFormat('hh:mm a');
|
|
final df2 = DateFormat(DateFormat.ABBR_MONTH_DAY);
|
|
String strDate = df1.format(DateTime.fromMillisecondsSinceEpoch(createdAt*1000));
|
|
strDate += " ${df2.format(DateTime.fromMillisecondsSinceEpoch(createdAt*1000))}";
|
|
return strDate;
|
|
}
|
|
|
|
/*
|
|
* Returns true if this is a valid direct message to just this user
|
|
*/
|
|
bool isValidDirectMessage(EventData directMessageData) {
|
|
bool validUserMessage = false;
|
|
|
|
List<String> allPtags = [];
|
|
directMessageData.tags.forEach((tag) {
|
|
if( tag.length < 2 )
|
|
return;
|
|
if( tag[0] == "p" && tag[1].length == 64) { // basic length sanity test
|
|
allPtags.add(tag[1]);
|
|
}
|
|
});
|
|
|
|
if( directMessageData.pubkey == userPublicKey && allPtags.length == 1) {
|
|
validUserMessage = true; // case where this user is sender
|
|
} else {
|
|
if ( directMessageData.pubkey != userPublicKey) {
|
|
if( allPtags.length == 1 && allPtags[0] == userPublicKey) {
|
|
validUserMessage = true; // case where this user is recipeint
|
|
}
|
|
}
|
|
}
|
|
return validUserMessage;
|
|
}
|
|
|
|
String getRandomPrivKey() {
|
|
FortunaRandom fr = FortunaRandom();
|
|
final _sGen = Random.secure();;
|
|
fr.seed(KeyParameter(
|
|
Uint8List.fromList(List.generate(32, (_) => _sGen.nextInt(255)))));
|
|
|
|
BigInt randomNumber = fr.nextBigInteger(256);
|
|
String strKey = randomNumber.toRadixString(16);
|
|
if( strKey.length < 64) {
|
|
int numZeros = 64 - strKey.length;
|
|
for(int i = 0; i < numZeros; i++) {
|
|
strKey = "0" + strKey;
|
|
}
|
|
}
|
|
return strKey;
|
|
}
|
|
|
|
// pointy castle source https://github.com/PointyCastle/pointycastle/blob/master/tutorials/aes-cbc.md
|
|
// https://github.com/bcgit/pc-dart/blob/master/tutorials/aes-cbc.md
|
|
// 3 https://github.com/Dhuliang/flutter-bsv/blob/42a2d92ec6bb9ee3231878ffe684e1b7940c7d49/lib/src/aescbc.dart
|
|
|
|
/// Decrypt data using self private key
|
|
String myPrivateDecrypt( String privateString,
|
|
String publicString,
|
|
String b64encoded,
|
|
[String b64IV = ""]) {
|
|
|
|
Uint8List encdData = convert.base64.decode(b64encoded);
|
|
final rawData = myPrivateDecryptRaw(privateString, publicString, encdData, b64IV);
|
|
return convert.Utf8Decoder().convert(rawData.toList());
|
|
}
|
|
|
|
|
|
Map<String, List<List<int>>> gMapByteSecret = {};
|
|
|
|
Uint8List myPrivateDecryptRaw( String privateString,
|
|
String publicString,
|
|
Uint8List cipherText,
|
|
[String b64IV = ""]) {
|
|
try {
|
|
|
|
List<List<int>> byteSecret = [];
|
|
if( gMapByteSecret.containsKey(publicString)) {
|
|
byteSecret = gMapByteSecret[publicString]??[];
|
|
}
|
|
|
|
if( byteSecret.isEmpty) {
|
|
byteSecret = Kepler.byteSecret(privateString, publicString);;
|
|
gMapByteSecret[publicString] = byteSecret;
|
|
}
|
|
|
|
final secretIV = byteSecret;
|
|
|
|
final key = Uint8List.fromList(secretIV[0]);
|
|
|
|
final iv = b64IV.length > 6
|
|
? convert.base64.decode(b64IV)
|
|
: Uint8List.fromList(secretIV[1]);
|
|
|
|
|
|
CipherParameters params = new PaddedBlockCipherParameters(
|
|
new ParametersWithIV(new KeyParameter(key), iv), null);
|
|
|
|
PaddedBlockCipherImpl cipherImpl = new PaddedBlockCipherImpl(
|
|
new PKCS7Padding(), new CBCBlockCipher(new AESEngine()));
|
|
|
|
|
|
cipherImpl.init(false,
|
|
params as PaddedBlockCipherParameters<CipherParameters?,
|
|
CipherParameters?>);
|
|
|
|
final Uint8List finalPlainText = Uint8List(cipherText.length); // allocate space
|
|
|
|
var offset = 0;
|
|
while (offset < cipherText.length - 16) {
|
|
offset += cipherImpl.processBlock(cipherText, offset, finalPlainText, offset);
|
|
}
|
|
|
|
//remove padding
|
|
offset += cipherImpl.doFinal(cipherText, offset, finalPlainText, offset);
|
|
assert(offset == cipherText.length);
|
|
return finalPlainText.sublist(0, offset);
|
|
} catch(e) {
|
|
if( gDebug >= 0) print("Decryption error = $e");
|
|
return Uint8List(0);
|
|
}
|
|
}
|
|
|
|
/// Encrypt data using self private key in nostr format ( with trailing ?iv=)
|
|
String myEncrypt( String privateString,
|
|
String publicString,
|
|
String plainText) {
|
|
Uint8List uintInputText = convert.Utf8Encoder().convert(plainText);
|
|
final encryptedString = myEncryptRaw(privateString, publicString, uintInputText);
|
|
return encryptedString;
|
|
}
|
|
|
|
String myEncryptRaw( String privateString,
|
|
String publicString,
|
|
Uint8List uintInputText) {
|
|
final secretIV = Kepler.byteSecret(privateString, publicString);
|
|
final key = Uint8List.fromList(secretIV[0]);
|
|
|
|
// generate iv https://stackoverflow.com/questions/63630661/aes-engine-not-initialised-with-pointycastle-securerandom
|
|
FortunaRandom fr = FortunaRandom();
|
|
final _sGen = Random.secure();;
|
|
fr.seed(KeyParameter(
|
|
Uint8List.fromList(List.generate(32, (_) => _sGen.nextInt(255)))));
|
|
final iv = fr.nextBytes(16); //Uint8List.fromList(secretIV[1]);
|
|
|
|
CipherParameters params = new PaddedBlockCipherParameters(
|
|
new ParametersWithIV(new KeyParameter(key), iv), null);
|
|
|
|
PaddedBlockCipherImpl cipherImpl = new PaddedBlockCipherImpl(
|
|
new PKCS7Padding(), new CBCBlockCipher(new AESEngine()));
|
|
|
|
cipherImpl.init(true, // means to encrypt
|
|
params as PaddedBlockCipherParameters<CipherParameters?,
|
|
CipherParameters?>);
|
|
|
|
final Uint8List outputEncodedText = Uint8List(uintInputText.length + 16); // allocate space
|
|
|
|
var offset = 0;
|
|
while (offset < uintInputText.length - 16) {
|
|
offset += cipherImpl.processBlock(uintInputText, offset, outputEncodedText, offset);
|
|
}
|
|
|
|
//add padding
|
|
offset += cipherImpl.doFinal(uintInputText, offset, outputEncodedText, offset);
|
|
assert(offset == uintInputText.length);
|
|
final Uint8List finalEncodedText = outputEncodedText.sublist(0, offset);
|
|
|
|
String stringIv = convert.base64.encode(iv);;
|
|
String outputPlainText = convert.base64.encode(finalEncodedText);
|
|
outputPlainText = outputPlainText + "?iv=" + stringIv;
|
|
return outputPlainText;
|
|
}
|
|
|
|
|
|
Set<Event> readEventsFromFile(String filename) {
|
|
Set<Event> events = {};
|
|
final File file = File(filename);
|
|
|
|
// sync read
|
|
try {
|
|
List<String> lines = file.readAsLinesSync();
|
|
for( int i = 0; i < lines.length; i++ ) {
|
|
Event e = Event.fromJson(lines[i], "", true);
|
|
events.add(e);
|
|
}
|
|
} on Exception catch(e) {
|
|
//print("cannot open file $gEventsFilename");
|
|
if( gDebug > 0) print("Could not open file. error = $e");
|
|
}
|
|
|
|
if( gDebug > 0) print("In readEventsFromFile: returning ${events.length} total events");
|
|
return events;
|
|
}
|
|
|
|
bool isValidPubkey(String pubkey) {
|
|
if( pubkey.length == 64) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
List<String> getEncryptedChannelKeys(List<DirectMessageRoom> directRooms, Map<String, Tree> tempChildEventsMap, String channelId) {
|
|
Event? e = tempChildEventsMap[channelId]?.event;
|
|
if( e != null) {
|
|
//print("\n----------------\nIn getEncryptedChannelKeys for encrypted channel $channelId");
|
|
String creatorPubKey = e.eventData.pubkey;
|
|
for( int i = 0; i < directRooms.length; i++) {
|
|
DirectMessageRoom room = directRooms[i];
|
|
if( room.otherPubkey == creatorPubKey) {
|
|
//print("got other pubkey $creatorPubKey");
|
|
for( int j = 0; j < room.messageIds.length; j++) {
|
|
String messageId = room.messageIds[j];
|
|
if( channelId == "14eda528a651fa55ac706280711d616f57ab449a9bc4a97efcd1e236e53a1044" ) {
|
|
//printInColor( "j = $j messageId = ${messageId}\n", redColor);
|
|
}
|
|
|
|
Event? messageEvent = tempChildEventsMap[messageId]?.event;
|
|
if( messageEvent != null) {
|
|
//print("got a message which is: ${messageEvent.eventData.evaluatedContent}");
|
|
//print(messageEvent.eventData.getStrForChannel(0));
|
|
String evaluatedContent = messageEvent.eventData.evaluatedContent;
|
|
if( evaluatedContent.startsWith("App Encrypted Channels:")) {
|
|
//print("got App");
|
|
if( channelId == "14eda528a651fa55ac706280711d616f57ab449a9bc4a97efcd1e236e53a1044" ) {
|
|
//print("evaluatedContent = $evaluatedContent");
|
|
//print("num messages: ${room.messageIds.length}");
|
|
}
|
|
|
|
if( evaluatedContent.contains(channelId) && evaluatedContent.length == 288) {
|
|
//print("in getEncryptedChannelKeys: success: got password in pvt message: $evaluatedContent");
|
|
//print("len of evaluated content: ${evaluatedContent.length} ");
|
|
String priKey = evaluatedContent.substring(159, 159 + 64);
|
|
String pubKey = evaluatedContent.substring(224, 224 + 64);
|
|
|
|
if( priKey.length == 64 && pubKey.length == 64) {
|
|
return [priKey, pubKey];
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
print("could not get message event");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
String myGetPublicKey(String prikey) {
|
|
String pubkey = getPublicKey(prikey);
|
|
|
|
if( pubkey.length < 64) {
|
|
int numZeros = 64 - pubkey.length;
|
|
for(int i = 0; i < numZeros; i++) {
|
|
pubkey = "0" + pubkey;
|
|
}
|
|
}
|
|
return pubkey;
|
|
}
|