From 1f8124b708eeb4be079c19a45c61f837d367313d Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 14 May 2025 11:39:10 +0100 Subject: [PATCH] feat: chat raiding closes #1 --- lib/rx_filter.dart | 19 +-- lib/utils.dart | 63 ++++++++++ lib/widgets/chat.dart | 194 +++---------------------------- lib/widgets/chat_raid.dart | 216 +++++++++++++++++++++++++++++++++++ lib/widgets/chat_zap.dart | 77 +++++++++++++ lib/widgets/goal.dart | 105 +++++++++++++++++ lib/widgets/profile.dart | 2 +- lib/widgets/stream_tile.dart | 6 +- 8 files changed, 491 insertions(+), 191 deletions(-) create mode 100644 lib/widgets/chat_raid.dart create mode 100644 lib/widgets/chat_zap.dart create mode 100644 lib/widgets/goal.dart diff --git a/lib/rx_filter.dart b/lib/rx_filter.dart index 5c7518d..50e47a8 100644 --- a/lib/rx_filter.dart +++ b/lib/rx_filter.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:collection'; import 'dart:developer' as developer; @@ -29,6 +30,7 @@ class RxFilter extends StatefulWidget { class _RxFilter extends State> { late NdkResponse _response; + late StreamSubscription _listener; HashMap? _events; @override @@ -45,19 +47,21 @@ class _RxFilter extends State> { ndk.requests.closeSubscription(_response.requestId); }); } - _response.stream + _listener = _response.stream .bufferTime(const Duration(milliseconds: 500)) .where((events) => events.isNotEmpty) .handleError((e) { developer.log("RX:ERROR $e"); }) .listen((events) { - setState(() { - developer.log( - "RX:GOT ${events.length} events for ${widget.filters}", - ); - events.forEach(_replaceInto); - }); + if (context.mounted) { + setState(() { + developer.log( + "RX:GOT ${events.length} events for ${widget.filters}", + ); + events.forEach(_replaceInto); + }); + } }); } @@ -88,6 +92,7 @@ class _RxFilter extends State> { super.dispose(); developer.log("RX:CLOSING ${widget.filters}"); + _listener.cancel(); ndk.requests.closeSubscription(_response.requestId); } diff --git a/lib/utils.dart b/lib/utils.dart index c871189..1f205ac 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:bech32/bech32.dart'; import 'package:collection/collection.dart'; import 'package:convert/convert.dart'; @@ -15,6 +17,23 @@ class StreamEvent { 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); } @@ -269,6 +288,25 @@ class TLV { final List 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 parseTLV(List data) { @@ -303,3 +341,28 @@ List parseTLV(List data) { return result; } + +List serializeTLV(List tlvs) { + List 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 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'); + } +} diff --git a/lib/widgets/chat.dart b/lib/widgets/chat.dart index 10d5a5b..d908914 100644 --- a/lib/widgets/chat.dart +++ b/lib/widgets/chat.dart @@ -5,9 +5,11 @@ import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/rx_filter.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; -import 'package:zap_stream_flutter/widgets/avatar.dart'; import 'package:zap_stream_flutter/widgets/chat_message.dart'; +import 'package:zap_stream_flutter/widgets/chat_raid.dart'; import 'package:zap_stream_flutter/widgets/chat_write.dart'; +import 'package:zap_stream_flutter/widgets/chat_zap.dart'; +import 'package:zap_stream_flutter/widgets/goal.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; class ChatWidget extends StatelessWidget { @@ -23,6 +25,7 @@ class ChatWidget extends StatelessWidget { var filters = [ Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]), + Filter(kinds: [1312], limit: 200, aTags: [stream.aTag]), Filter(kinds: [Nip51List.kMute], authors: muteLists), ]; return RxFilter( @@ -64,8 +67,7 @@ class ChatWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (zaps.isNotEmpty) _TopZappersWidget(events: zaps), - if (stream.info.goal != null) - _StreamGoalWidget.id(stream.info.goal!), + if (stream.info.goal != null) GoalWidget.id(stream.info.goal!), Expanded( child: ListView.builder( reverse: true, @@ -78,12 +80,16 @@ class ChatWidget extends StatelessWidget { stream: stream, msg: filteredChat[idx], ), - 9735 => _ChatZapWidget( + 1312 => ChatRaidMessage( + event: filteredChat[idx], + stream: stream, + ), + 9735 => ChatZapWidget( key: Key("chat:${filteredChat[idx].id}"), stream: stream, zap: filteredChat[idx], ), - _ => SizedBox.shrink(), + _ => SizedBox(), }, ), ), @@ -92,10 +98,13 @@ class ChatWidget extends StatelessWidget { if (stream.info.status == StreamStatus.ended) Container( padding: EdgeInsets.all(8), - margin: EdgeInsets.symmetric(vertical: 8), + margin: EdgeInsets.only(bottom: 8, top: 4), width: double.maxFinite, alignment: Alignment.center, - decoration: BoxDecoration(borderRadius: DEFAULT_BR), + decoration: BoxDecoration( + borderRadius: DEFAULT_BR, + color: PRIMARY_1, + ), child: Text( "STREAM ENDED", style: TextStyle(fontWeight: FontWeight.bold), @@ -108,106 +117,6 @@ class ChatWidget extends StatelessWidget { } } -class _StreamGoalWidget extends StatelessWidget { - final Nip01Event goal; - - const _StreamGoalWidget({required this.goal}); - - static Widget id(String id) { - return RxFilter( - Key("stream:goal:$id"), - leaveOpen: false, - filters: [ - Filter(kinds: [9041], ids: [id]), - ], - builder: (ctx, state) { - final goal = state?.firstOrNull; - return goal != null ? _StreamGoalWidget(goal: goal) : SizedBox.shrink(); - }, - ); - } - - @override - Widget build(BuildContext context) { - final max = int.parse(goal.getFirstTag("amount") ?? "1"); - return Container( - padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: RxFilter( - Key("stream:goal:$id:zaps"), - filters: [ - Filter(kinds: [9735], eTags: [goal.id]), - ], - builder: (ctx, state) { - final zaps = (state ?? []).map((e) => ZapReceipt.fromEvent(e)); - final totalZaps = - zaps.fold(0, (acc, v) => acc + (v.amountSats ?? 0)) * 1000; - final progress = totalZaps / max; - final remaining = ((max - totalZaps).clamp(0, max) / 1000).toInt(); - - final q = MediaQuery.of(ctx); - return Column( - spacing: 4, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded(child: Text(goal.content)), - if (remaining > 0) - Text( - "Remaining: ${formatSats(remaining)}", - style: TextStyle(fontSize: 10, color: LAYER_5), - ), - ], - ), - Stack( - children: [ - Container( - height: 10, - decoration: BoxDecoration( - color: LAYER_2, - borderRadius: DEFAULT_BR, - ), - ), - Container( - height: 10, - width: (q.size.width * progress).clamp(1, q.size.width), - decoration: BoxDecoration( - color: ZAP_1, - borderRadius: DEFAULT_BR, - ), - ), - if (remaining > 0) - Positioned( - right: 2, - child: Text( - "Goal: ${formatSats((max / 1000).toInt())}", - style: TextStyle( - fontSize: 8, - fontWeight: FontWeight.bold, - ), - ), - ), - if (remaining == 0) - Center( - child: Text( - "COMPLETE", - style: TextStyle( - color: LAYER_0, - fontSize: 8, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ], - ); - }, - ), - ); - } -} - class _TopZappersWidget extends StatelessWidget { final List events; @@ -260,74 +169,3 @@ class _TopZappersWidget extends StatelessWidget { ); } } - -class _ChatZapWidget extends StatelessWidget { - final StreamEvent stream; - final Nip01Event zap; - - const _ChatZapWidget({required this.stream, required this.zap, super.key}); - - @override - Widget build(BuildContext context) { - final parsed = ZapReceipt.fromEvent(zap); - return Container( - margin: EdgeInsets.symmetric(vertical: 4), - padding: EdgeInsets.all(8), - decoration: BoxDecoration( - border: Border.all(color: ZAP_1), - borderRadius: DEFAULT_BR, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _zapperRowZap(parsed), - if (parsed.comment?.isNotEmpty ?? false) Text(parsed.comment!), - ], - ), - ); - } - - Widget _zapperRowZap(ZapReceipt parsed) { - if (parsed.sender != null) { - return ProfileLoaderWidget(parsed.sender!, (ctx, state) { - final name = ProfileNameWidget.nameFromProfile( - state.data ?? Metadata(pubKey: parsed.sender!), - ); - return _zapperRow(name, parsed.amountSats ?? 0, state.data); - }); - } else { - return _zapperRow("Anon", parsed.amountSats ?? 0, null); - } - } - - Widget _zapperRow(String name, int amount, Metadata? profile) { - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - RichText( - text: TextSpan( - style: TextStyle(color: ZAP_1), - children: [ - WidgetSpan( - child: Icon(Icons.bolt, color: ZAP_1), - alignment: PlaceholderAlignment.middle, - ), - if (profile != null) - WidgetSpan( - child: Padding( - padding: EdgeInsets.only(right: 8), - child: AvatarWidget(profile: profile, size: 20), - ), - alignment: PlaceholderAlignment.middle, - ), - TextSpan(text: name), - TextSpan(text: " zapped ", style: TextStyle(color: FONT_COLOR)), - TextSpan(text: formatSats(amount)), - TextSpan(text: " sats", style: TextStyle(color: FONT_COLOR)), - ], - ), - ), - ], - ); - } -} diff --git a/lib/widgets/chat_raid.dart b/lib/widgets/chat_raid.dart new file mode 100644 index 0000000..68537c5 --- /dev/null +++ b/lib/widgets/chat_raid.dart @@ -0,0 +1,216 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/main.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/profile.dart'; + +class ChatRaidMessage extends StatefulWidget { + final StreamEvent stream; + final Nip01Event event; + + const ChatRaidMessage({super.key, required this.stream, required this.event}); + + @override + State createState() => __ChatRaidMessage(); +} + +class __ChatRaidMessage extends State + with SingleTickerProviderStateMixin { + late final String? _from; + late final String? _to; + late final bool _isRaiding; + + DateTime? _raidingAt; + + @override + void initState() { + super.initState(); + + _from = + widget.event.tags.firstWhereOrNull( + (t) => t[0] == "a" && (t[3] == "from" || t[3] == "root"), + )?[1]; + _to = + widget.event.tags.firstWhereOrNull( + (t) => t[0] == "a" && (t[3] == "to" || t[3] == "mention"), + )?[1]; + _isRaiding = _from == widget.stream.aTag; + final isAutoRaid = + ((DateTime.now().millisecondsSinceEpoch / 1000) - + widget.event.createdAt) + .abs() < + 60; + if (isAutoRaid) { + final autoRaidDelay = Duration(seconds: 5); + _raidingAt = DateTime.now().add(autoRaidDelay); + } + } + + @override + Widget build(BuildContext context) { + if (_from == null || _to == null) return SizedBox.shrink(); + + final otherLink = (_isRaiding ? _to : _from).split(":"); + final otherEvent = ndk.requests.query( + filters: [ + Filter( + kinds: [int.parse(otherLink[0])], + authors: [otherLink[1]], + dTags: [otherLink[2]], + ), + ], + ); + + return Container( + padding: EdgeInsets.all(8), + margin: EdgeInsets.symmetric(vertical: 4), + width: double.maxFinite, + alignment: Alignment.center, + decoration: BoxDecoration(borderRadius: DEFAULT_BR, color: PRIMARY_1), + child: FutureBuilder( + future: otherEvent.future, + builder: (ctx, state) { + final otherStream = state.data?.firstWhereOrNull( + (e) => e.getDtag() == otherLink[2] && e.pubKey == otherLink[1], + ); + if (otherStream == null) return SizedBox.shrink(); + final otherStreamEvent = StreamEvent(otherStream); + return Column( + children: [ + RichText( + text: TextSpan( + style: TextStyle(fontWeight: FontWeight.bold), + children: [ + TextSpan(text: _isRaiding ? "RAIDING " : "RAID FROM "), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ProfileLoaderWidget(otherStreamEvent.info.host, ( + ctx, + profile, + ) { + return Text( + ProfileNameWidget.nameFromProfile( + profile.data ?? + Metadata(pubKey: otherStreamEvent.info.host), + ).toUpperCase(), + style: TextStyle(fontWeight: FontWeight.bold), + ); + }), + ), + if (_raidingAt == null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + onTap: () { + context.go( + "/e/${otherStreamEvent.link}", + extra: otherStreamEvent, + ); + }, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.open_in_new, size: 15), + ), + ), + ), + ], + ), + ), + if (_raidingAt != null) + RichText( + text: TextSpan( + children: [ + TextSpan(text: "Raiding in "), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: CountdownTimer( + triggerAt: _raidingAt!, + onTrigger: () { + context.go( + "/e/${otherStreamEvent.link}", + extra: otherStreamEvent, + ); + }, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +class CountdownTimer extends StatefulWidget { + final void Function() onTrigger; + final TextStyle? style; + final DateTime triggerAt; + + const CountdownTimer({ + super.key, + required this.onTrigger, + this.style, + required this.triggerAt, + }); + + @override + createState() => _CountdownTimerState(); +} + +class _CountdownTimerState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + bool _actionTriggered = false; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + final countdown = + widget.triggerAt.isBefore(now) + ? Duration() + : widget.triggerAt.difference(now); + + _controller = AnimationController(vsync: this, duration: countdown); + + // Create animation to track progress from 5 to 0 + _animation = Tween( + begin: countdown.inSeconds.toDouble(), + end: 0, + ).animate(_controller)..addStatusListener((status) { + if (status == AnimationStatus.completed && !_actionTriggered) { + setState(() { + _actionTriggered = true; + widget.onTrigger(); + }); + } + }); + + // Start the countdown immediately when widget is mounted + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); // Clean up the controller + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final secondsLeft = _animation.value.ceil(); + return Text(secondsLeft.toString(), style: widget.style); + }, + ); + } +} diff --git a/lib/widgets/chat_zap.dart b/lib/widgets/chat_zap.dart new file mode 100644 index 0000000..d508210 --- /dev/null +++ b/lib/widgets/chat_zap.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/avatar.dart'; +import 'package:zap_stream_flutter/widgets/profile.dart'; + +class ChatZapWidget extends StatelessWidget { + final StreamEvent stream; + final Nip01Event zap; + + const ChatZapWidget({required this.stream, required this.zap, super.key}); + + @override + Widget build(BuildContext context) { + final parsed = ZapReceipt.fromEvent(zap); + return Container( + margin: EdgeInsets.symmetric(vertical: 4), + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: ZAP_1), + borderRadius: DEFAULT_BR, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _zapperRowZap(parsed), + if (parsed.comment?.isNotEmpty ?? false) Text(parsed.comment!), + ], + ), + ); + } + + Widget _zapperRowZap(ZapReceipt parsed) { + if (parsed.sender != null) { + return ProfileLoaderWidget(parsed.sender!, (ctx, state) { + final name = ProfileNameWidget.nameFromProfile( + state.data ?? Metadata(pubKey: parsed.sender!), + ); + return _zapperRow(name, parsed.amountSats ?? 0, state.data); + }); + } else { + return _zapperRow("Anon", parsed.amountSats ?? 0, null); + } + } + + Widget _zapperRow(String name, int amount, Metadata? profile) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + RichText( + text: TextSpan( + style: TextStyle(color: ZAP_1), + children: [ + WidgetSpan( + child: Icon(Icons.bolt, color: ZAP_1), + alignment: PlaceholderAlignment.middle, + ), + if (profile != null) + WidgetSpan( + child: Padding( + padding: EdgeInsets.only(right: 8), + child: AvatarWidget(profile: profile, size: 20), + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: name), + TextSpan(text: " zapped ", style: TextStyle(color: FONT_COLOR)), + TextSpan(text: formatSats(amount)), + TextSpan(text: " sats", style: TextStyle(color: FONT_COLOR)), + ], + ), + ), + ], + ); + } +} diff --git a/lib/widgets/goal.dart b/lib/widgets/goal.dart new file mode 100644 index 0000000..460ab60 --- /dev/null +++ b/lib/widgets/goal.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/rx_filter.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; + +class GoalWidget extends StatelessWidget { + final Nip01Event goal; + + const GoalWidget({super.key, required this.goal}); + + static Widget id(String id) { + return RxFilter( + Key("stream:goal:$id"), + leaveOpen: false, + filters: [ + Filter(kinds: [9041], ids: [id]), + ], + builder: (ctx, state) { + final goal = state?.firstOrNull; + return goal != null ? GoalWidget(goal: goal) : SizedBox.shrink(); + }, + ); + } + + @override + Widget build(BuildContext context) { + final max = int.parse(goal.getFirstTag("amount") ?? "1"); + return Container( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: RxFilter( + Key("goal:$id:zaps"), + filters: [ + Filter(kinds: [9735], eTags: [goal.id]), + ], + builder: (ctx, state) { + final zaps = (state ?? []).map((e) => ZapReceipt.fromEvent(e)); + final totalZaps = + zaps.fold(0, (acc, v) => acc + (v.amountSats ?? 0)) * 1000; + final progress = totalZaps / max; + final remaining = ((max - totalZaps).clamp(0, max) / 1000).toInt(); + + final q = MediaQuery.of(ctx); + return Column( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(goal.content)), + if (remaining > 0) + Text( + "Remaining: ${formatSats(remaining)}", + style: TextStyle(fontSize: 10, color: LAYER_5), + ), + ], + ), + Stack( + children: [ + Container( + height: 10, + decoration: BoxDecoration( + color: LAYER_2, + borderRadius: DEFAULT_BR, + ), + ), + Container( + height: 10, + width: (q.size.width * progress).clamp(1, q.size.width), + decoration: BoxDecoration( + color: ZAP_1, + borderRadius: DEFAULT_BR, + ), + ), + if (remaining > 0) + Positioned( + right: 2, + child: Text( + "Goal: ${formatSats((max / 1000).toInt())}", + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + if (remaining == 0) + Center( + child: Text( + "COMPLETE", + style: TextStyle( + color: LAYER_0, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index f437ddd..8c623b9 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -38,7 +38,7 @@ class ProfileNameWidget extends StatelessWidget { ); } - static nameFromProfile(Metadata profile) { + static String nameFromProfile(Metadata profile) { if ((profile.displayName?.length ?? 0) > 0) { return profile.displayName!; } diff --git a/lib/widgets/stream_tile.dart b/lib/widgets/stream_tile.dart index bee51fd..d7ec290 100644 --- a/lib/widgets/stream_tile.dart +++ b/lib/widgets/stream_tile.dart @@ -2,7 +2,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; -import 'package:ndk/shared/nips/nip19/nip19.dart'; import 'package:zap_stream_flutter/imgproxy.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; @@ -19,10 +18,7 @@ class StreamTileWidget extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () { - context.push( - "/e/${Nip19.encodeNoteId(stream.event.id)}", - extra: stream, - ); + context.push("/e/${stream.link}", extra: stream); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start,