mirror of
https://github.com/nostrlabs-io/zap-stream-flutter.git
synced 2025-06-14 11:27:43 +00:00
580 lines
14 KiB
Dart
580 lines
14 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:bech32/bech32.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:convert/convert.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:ndk/ndk.dart';
|
|
import 'package:ndk/shared/nips/nip19/hrps.dart';
|
|
import 'package:ndk/shared/nips/nip19/nip19.dart';
|
|
|
|
/// Container class over event and stream info
|
|
class StreamEvent {
|
|
late final StreamInfo info;
|
|
final Nip01Event event;
|
|
|
|
/// Return the 'a' tag for this stream
|
|
String get aTag {
|
|
return "${event.kind}:${event.pubKey}:${info.id}";
|
|
}
|
|
|
|
/// Get the naddr for this stream
|
|
String get link {
|
|
final k = event.kind & 0xFFFFFFFF;
|
|
final idData = utf8.encode(info.id!);
|
|
final tlv = [
|
|
TLV(0, idData.length, idData),
|
|
TLV(2, 32, hex.decode(event.pubKey)),
|
|
TLV(3, 4, [
|
|
(k >> 24) & 0xFF,
|
|
(k >> 16) & 0xFF,
|
|
(k >> 8) & 0xFF,
|
|
k & 0xFF,
|
|
]),
|
|
];
|
|
return encodeBech32TLV("naddr", tlv);
|
|
}
|
|
|
|
StreamEvent(this.event) {
|
|
info = extractStreamInfo(event);
|
|
}
|
|
}
|
|
|
|
enum StreamStatus { live, ended, planned }
|
|
|
|
/// Extracted tags from NIP-53 event
|
|
class StreamInfo {
|
|
String? id;
|
|
String? title;
|
|
String? summary;
|
|
String? image;
|
|
String? thumbnail;
|
|
StreamStatus? status;
|
|
String? stream;
|
|
String? recording;
|
|
String? contentWarning;
|
|
List<String> tags;
|
|
String? goal;
|
|
int? participants;
|
|
int? starts;
|
|
int? ends;
|
|
String? service;
|
|
String host;
|
|
String? gameId;
|
|
GameInfo? gameInfo;
|
|
List<String> streams;
|
|
List<String>? relays;
|
|
|
|
StreamInfo({
|
|
this.id,
|
|
this.title,
|
|
this.summary,
|
|
this.image,
|
|
this.thumbnail,
|
|
this.status,
|
|
this.stream,
|
|
this.recording,
|
|
this.contentWarning,
|
|
this.tags = const [],
|
|
this.goal,
|
|
this.participants,
|
|
this.starts,
|
|
this.ends,
|
|
this.service,
|
|
required this.host,
|
|
this.gameId,
|
|
this.gameInfo,
|
|
this.streams = const [],
|
|
});
|
|
}
|
|
|
|
class GameInfo {
|
|
final String id;
|
|
final String name;
|
|
final List<String> genres;
|
|
final String? coverImage;
|
|
|
|
const GameInfo({
|
|
required this.id,
|
|
required this.name,
|
|
required this.genres,
|
|
required this.coverImage,
|
|
});
|
|
}
|
|
|
|
final RegExp gameTagFormat = RegExp(
|
|
r'^[a-z-]+:[a-z0-9-]+$',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
StreamInfo extractStreamInfo(Nip01Event ev) {
|
|
var ret = StreamInfo(host: getHost(ev));
|
|
|
|
void matchTag(List<String> tag, String k, void Function(String) into) {
|
|
if (tag[0] == k) {
|
|
into(tag[1]);
|
|
}
|
|
}
|
|
|
|
for (var t in ev.tags) {
|
|
matchTag(t, 'd', (v) => ret.id = v);
|
|
matchTag(t, 'title', (v) => ret.title = v);
|
|
matchTag(t, 'summary', (v) => ret.summary = v);
|
|
matchTag(t, 'image', (v) => ret.image = v);
|
|
matchTag(t, 'thumbnail', (v) => ret.thumbnail = v);
|
|
matchTag(
|
|
t,
|
|
'status',
|
|
(v) =>
|
|
ret.status = switch (v.toLowerCase()) {
|
|
'live' => StreamStatus.live,
|
|
'ended' => StreamStatus.ended,
|
|
'planned' => StreamStatus.planned,
|
|
_ => null,
|
|
},
|
|
);
|
|
if (t[0] == 'streaming') {
|
|
ret.streams = [...ret.streams, t[1]];
|
|
}
|
|
matchTag(t, 'recording', (v) => ret.recording = v);
|
|
matchTag(t, 'url', (v) => ret.recording = v);
|
|
matchTag(t, 'content-warning', (v) => ret.contentWarning = v);
|
|
matchTag(
|
|
t,
|
|
'current_participants',
|
|
(v) => ret.participants = int.tryParse(v),
|
|
);
|
|
matchTag(t, 'goal', (v) => ret.goal = v);
|
|
matchTag(t, 'starts', (v) => ret.starts = int.tryParse(v));
|
|
matchTag(t, 'ends', (v) => ret.ends = int.tryParse(v));
|
|
matchTag(t, 'service', (v) => ret.service = v);
|
|
if (t[0] == "relays") {
|
|
ret.relays = t.slice(1);
|
|
if (ret.relays!.isEmpty) {
|
|
ret.relays = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
var sortedTags = sortStreamTags(ev.tags);
|
|
ret.tags = sortedTags.regularTags;
|
|
|
|
var gameTag = extractGameTag(sortedTags.prefixedTags);
|
|
ret.gameId = gameTag.gameId;
|
|
ret.gameInfo = gameTag.gameInfo;
|
|
|
|
if (ret.streams.isNotEmpty) {
|
|
var isN94 = ret.streams.contains('nip94');
|
|
if (isN94) {
|
|
ret.stream = 'nip94';
|
|
} else {
|
|
ret.stream = ret.streams.firstWhereOrNull((a) => a.contains('.m3u8'));
|
|
}
|
|
if (ret.status == StreamStatus.ended &&
|
|
(ret.recording?.isNotEmpty ?? false)) {
|
|
ret.stream = ret.recording;
|
|
}
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
({List<String> regularTags, List<String> prefixedTags}) sortStreamTags(
|
|
List<dynamic> tags,
|
|
) {
|
|
var plainTags =
|
|
tags
|
|
.where((a) => a is List<String> ? a[0] == 't' : true)
|
|
.map((a) => a is List<String> ? a[1] : a as String)
|
|
.toList();
|
|
|
|
var regularTags = plainTags.where((a) => !gameTagFormat.hasMatch(a)).toList();
|
|
var prefixedTags = plainTags.where((a) => !regularTags.contains(a)).toList();
|
|
|
|
return (regularTags: regularTags, prefixedTags: prefixedTags);
|
|
}
|
|
|
|
({GameInfo? gameInfo, String? gameId}) extractGameTag(List<String> tags) {
|
|
final gameId = tags.firstWhereOrNull((a) => gameTagFormat.hasMatch(a));
|
|
|
|
final internalGame = allCategories.firstWhereOrNull(
|
|
(a) => gameId == 'internal:${a.id}',
|
|
);
|
|
if (internalGame != null) {
|
|
return (
|
|
gameInfo: GameInfo(
|
|
id: "internal:${internalGame.id}",
|
|
name: internalGame.name,
|
|
genres: internalGame.tags,
|
|
coverImage: internalGame.coverImage,
|
|
),
|
|
gameId: gameId,
|
|
);
|
|
}
|
|
|
|
final lowerTags = tags.map((t) => t.toLowerCase());
|
|
final taggedCategory = allCategories.firstWhereOrNull(
|
|
(a) => a.tags.any(lowerTags.contains),
|
|
);
|
|
if (taggedCategory != null) {
|
|
return (
|
|
gameInfo: GameInfo(
|
|
id: "internal:${taggedCategory.id}",
|
|
name: taggedCategory.name,
|
|
genres: taggedCategory.tags,
|
|
coverImage: taggedCategory.coverImage,
|
|
),
|
|
gameId: gameId,
|
|
);
|
|
}
|
|
return (gameInfo: null, gameId: gameId);
|
|
}
|
|
|
|
String getHost(Nip01Event ev) {
|
|
return ev.tags.firstWhere(
|
|
(t) => t[0] == "p" && t.length > 3 && t[3] == "host",
|
|
orElse: () => ["p", ev.pubKey], // fake p tag with event pubkey
|
|
)[1];
|
|
}
|
|
|
|
class Category {
|
|
final String id;
|
|
final String name;
|
|
final IconData icon;
|
|
final List<String> tags;
|
|
final int order;
|
|
final String? coverImage;
|
|
|
|
const Category({
|
|
required this.id,
|
|
required this.name,
|
|
required this.icon,
|
|
required this.tags,
|
|
required this.order,
|
|
this.coverImage,
|
|
});
|
|
}
|
|
|
|
const List<Category> allCategories = [
|
|
Category(
|
|
id: "irl",
|
|
name: "IRL",
|
|
icon: Icons.face,
|
|
tags: ["irl"],
|
|
order: 0,
|
|
coverImage: "assets/category/irl.jpeg",
|
|
),
|
|
Category(
|
|
id: "gaming",
|
|
name: "Gaming",
|
|
icon: Icons.gamepad,
|
|
tags: ["gaming"],
|
|
order: 0,
|
|
coverImage: "assets/category/gaming.jpeg",
|
|
),
|
|
Category(
|
|
id: "music",
|
|
name: "Music",
|
|
icon: Icons.note,
|
|
tags: ["music", "raido"],
|
|
order: 0,
|
|
coverImage: "assets/category/music.jpeg",
|
|
),
|
|
Category(
|
|
id: "talk",
|
|
name: "Talk",
|
|
icon: Icons.mic,
|
|
tags: ["talk", "just-chatting"],
|
|
order: 0,
|
|
coverImage: "assets/category/talk.jpeg",
|
|
),
|
|
Category(
|
|
id: "art",
|
|
name: "Art",
|
|
icon: Icons.brush,
|
|
tags: ["art"],
|
|
order: 0,
|
|
coverImage: "assets/category/art.jpeg",
|
|
),
|
|
Category(
|
|
id: "gambling",
|
|
name: "Gambling",
|
|
icon: Icons.casino,
|
|
tags: ["gambling", "casino", "slots"],
|
|
order: 1,
|
|
),
|
|
Category(
|
|
id: "science-and-technology",
|
|
name: "Science & Technology",
|
|
icon: Icons.casino,
|
|
tags: ["science", "tech", "technology"],
|
|
order: 1,
|
|
),
|
|
];
|
|
|
|
String formatSats(int n, {int? maxDigits}) {
|
|
final fmt = NumberFormat();
|
|
fmt.maximumFractionDigits = maxDigits ?? 2;
|
|
if (n >= 1000000) {
|
|
return "${fmt.format(n / 1000000)}M";
|
|
} else if (n >= 1500) {
|
|
return "${fmt.format(n / 1000)}K";
|
|
} else {
|
|
return fmt.format(n);
|
|
}
|
|
}
|
|
|
|
String zapSum(List<Nip01Event> zaps) {
|
|
final total = zaps
|
|
.map((e) => ZapReceipt.fromEvent(e))
|
|
.fold(0, (acc, v) => acc + (v.amountSats ?? 0));
|
|
return formatSats(total);
|
|
}
|
|
|
|
class TopZaps {
|
|
final int sum;
|
|
final List<ZapReceipt> zaps;
|
|
|
|
const TopZaps({required this.sum, required this.zaps});
|
|
}
|
|
|
|
Map<String, TopZaps> topZapSender(Iterable<ZapReceipt> zaps) {
|
|
return Map.fromEntries(
|
|
zaps
|
|
.where((e) => e.sender != null)
|
|
.groupListsBy((v) => v.sender!)
|
|
.entries
|
|
.map(
|
|
(v) => MapEntry(
|
|
v.key,
|
|
TopZaps(
|
|
sum: v.value.fold(0, (acc, v) => acc + (v.amountSats ?? 0)),
|
|
zaps: v.value,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Map<String, TopZaps> topZapReceiver(Iterable<ZapReceipt> zaps) {
|
|
return Map.fromEntries(
|
|
zaps
|
|
.where((e) => e.recipient != null)
|
|
.groupListsBy((v) => v.recipient!)
|
|
.entries
|
|
.map(
|
|
(v) => MapEntry(
|
|
v.key,
|
|
TopZaps(
|
|
sum: v.value.fold(0, (acc, v) => acc + (v.amountSats ?? 0)),
|
|
zaps: v.value,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String bech32ToHex(String bech32) {
|
|
final decoder = Bech32Decoder();
|
|
final data = decoder.convert(bech32, 10_000);
|
|
final data8bit = Nip19.convertBits(data.data, 5, 8, false);
|
|
if (data.hrp == "nevent" || data.hrp == "naddr" || data.hrp == "nprofile") {
|
|
final tlv = parseTLV(data8bit);
|
|
return hex.encode(tlv.firstWhere((v) => v.type == 0).value);
|
|
} else {
|
|
return hex.encode(data8bit);
|
|
}
|
|
}
|
|
|
|
// https://github.com/nostr-protocol/nips/blob/master/19.md
|
|
class TLVTypes {
|
|
static const int kSpecial = 0;
|
|
static const int kRelay = 1;
|
|
static const int kAuthor = 2;
|
|
static const int kKind = 3;
|
|
}
|
|
|
|
class TLVEntity {
|
|
final String hrp;
|
|
final List<TLV> data;
|
|
|
|
const TLVEntity(this.hrp, this.data);
|
|
|
|
TLV? get special {
|
|
return data.firstWhereOrNull((e) => e.type == TLVTypes.kSpecial);
|
|
}
|
|
|
|
/// return the special entry as hex
|
|
String? get specialHex {
|
|
final r = special;
|
|
return r != null ? hex.encode(r.value) : null;
|
|
}
|
|
|
|
/// return the special entry as utf8 string
|
|
String? get specialUtf8 {
|
|
final r = special;
|
|
return r != null ? utf8.decode(r.value) : null;
|
|
}
|
|
|
|
int? get kind {
|
|
final k = data.firstWhereOrNull((e) => e.type == TLVTypes.kKind);
|
|
return k != null
|
|
? k.value[0] << 24 | k.value[1] << 16 | k.value[2] << 8 | k.value[3]
|
|
: null;
|
|
}
|
|
|
|
String? get author {
|
|
final a = data.firstWhereOrNull((e) => e.type == TLVTypes.kAuthor);
|
|
return a != null ? hex.encode(a.value) : null;
|
|
}
|
|
|
|
List<String>? get relays {
|
|
final r = data.where((r) => r.type == TLVTypes.kRelay);
|
|
if (r.isNotEmpty) {
|
|
return r.map((e) => utf8.decode(e.value)).toList();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Filter toFilter() {
|
|
var ret = <String, dynamic>{};
|
|
if (hrp == Hrps.kNaddr) {
|
|
final dTag = specialUtf8;
|
|
final kindValue = kind;
|
|
final authorValue = author;
|
|
if (dTag == null || kindValue == null || authorValue == null) {
|
|
throw "Invalid naddr entity, special, kind and author must be set";
|
|
}
|
|
ret["#d"] = [dTag];
|
|
ret["authors"] = [authorValue];
|
|
ret["kinds"] = [kindValue];
|
|
} else if (hrp == Hrps.kNevent) {
|
|
final idValue = specialHex;
|
|
if (idValue == null) {
|
|
throw "Invalid nevent, special entry is invalid or missing";
|
|
}
|
|
ret["ids"] = [idValue];
|
|
final kindValue = kind;
|
|
if (kindValue != null) {
|
|
ret["kinds"] = [kindValue];
|
|
}
|
|
final authorValue = author;
|
|
if (authorValue != null) {
|
|
ret["authors"] = [authorValue];
|
|
}
|
|
} else if (hrp == Hrps.kNoteId) {
|
|
final idValue = specialHex;
|
|
if (idValue == null) {
|
|
throw "Invalid nevent, special entry is invalid or missing";
|
|
}
|
|
ret["ids"] = [idValue];
|
|
} else {
|
|
throw "Cant convert $hrp to a filter";
|
|
}
|
|
return Filter.fromMap(ret);
|
|
}
|
|
}
|
|
|
|
class TLV {
|
|
final int type;
|
|
final int length;
|
|
final List<int> value;
|
|
|
|
TLV(this.type, this.length, this.value);
|
|
|
|
void validate() {
|
|
if (type < 0 || type > 255) {
|
|
throw ArgumentError('Type must be 0-255 (1 byte)');
|
|
}
|
|
if (length < 0 || length > 255) {
|
|
throw ArgumentError('Length must be 0-255 (1 byte)');
|
|
}
|
|
if (length != value.length) {
|
|
throw ArgumentError(
|
|
'Length ($length) does not match value length (${value.length})',
|
|
);
|
|
}
|
|
for (var byte in value) {
|
|
if (byte < 0 || byte > 255) {
|
|
throw ArgumentError('Value bytes must be 0-255');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
List<TLV> parseTLV(List<int> data) {
|
|
List<TLV> result = [];
|
|
int index = 0;
|
|
|
|
while (index < data.length) {
|
|
// Check if we have enough bytes for type and length
|
|
if (index + 2 > data.length) {
|
|
throw FormatException('Incomplete TLV data');
|
|
}
|
|
|
|
// Read type (1 byte)
|
|
int type = data[index];
|
|
index++;
|
|
|
|
// Read length (1 byte)
|
|
int length = data[index];
|
|
index++;
|
|
|
|
// Check if we have enough bytes for value
|
|
if (index + length > data.length) {
|
|
throw FormatException('TLV value length exceeds available data');
|
|
}
|
|
|
|
// Read value
|
|
List<int> value = data.sublist(index, index + length);
|
|
index += length;
|
|
|
|
result.add(TLV(type, length, value));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
List<int> serializeTLV(List<TLV> tlvs) {
|
|
List<int> result = [];
|
|
|
|
for (var tlv in tlvs) {
|
|
tlv.validate();
|
|
result.add(tlv.type);
|
|
result.add(tlv.length);
|
|
result.addAll(tlv.value);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Encodes TLV data into a Bech32 string
|
|
String encodeBech32TLV(String hrp, List<TLV> tlvs) {
|
|
try {
|
|
final data8bit = serializeTLV(tlvs);
|
|
final data5bit = Nip19.convertBits(data8bit, 8, 5, true);
|
|
final bech32Data = Bech32(hrp, data5bit);
|
|
return bech32.encode(bech32Data, 10_000);
|
|
} catch (e) {
|
|
throw FormatException('Failed to encode Bech32 or TLV: $e');
|
|
}
|
|
}
|
|
|
|
TLVEntity decodeBech32ToTLVEntity(String input) {
|
|
final decoder = Bech32Decoder();
|
|
final data = decoder.convert(input, 10_000);
|
|
final data8bit = Nip19.convertBits(data.data, 5, 8, false);
|
|
if (data.hrp != "npub" || data.hrp != "nsec" || data.hrp != "note") {
|
|
return TLVEntity(data.hrp, parseTLV(data8bit));
|
|
} else {
|
|
// convert to basic type using special entry only
|
|
return TLVEntity(data.hrp, [TLV(0, data8bit.length, data8bit)]);
|
|
}
|
|
}
|
|
|
|
Filter aTagToFilter(String tag) {
|
|
final ts = tag.split(":");
|
|
return Filter(kinds: [int.parse(ts[0])], authors: [ts[1]], dTags: [ts[2]]);
|
|
}
|