feat: chat raiding

closes #1
This commit is contained in:
2025-05-14 11:39:10 +01:00
parent 42d9293ecb
commit 1f8124b708
8 changed files with 491 additions and 191 deletions

View File

@ -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<Nip01Event>(
@ -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<Nip01Event>(
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<Nip01Event>(
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<ZapReceipt> 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)),
],
),
),
],
);
}
}

216
lib/widgets/chat_raid.dart Normal file
View File

@ -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<StatefulWidget> createState() => __ChatRaidMessage();
}
class __ChatRaidMessage extends State<ChatRaidMessage>
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<CountdownTimer>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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<double>(
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);
},
);
}
}

77
lib/widgets/chat_zap.dart Normal file
View File

@ -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)),
],
),
),
],
);
}
}

105
lib/widgets/goal.dart Normal file
View File

@ -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<Nip01Event>(
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<Nip01Event>(
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,
),
),
),
],
),
],
);
},
),
);
}
}

View File

@ -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!;
}

View File

@ -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,