From a5736aa3d3181e58ef1fb84d8fce4067bf79078c Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 19 May 2025 16:32:21 +0100 Subject: [PATCH] feat: timeouts closes #13 --- lib/widgets/button.dart | 2 +- lib/widgets/chat.dart | 87 ++++++++++++++++++++++++++++------- lib/widgets/chat_message.dart | 7 ++- lib/widgets/chat_modal.dart | 80 ++++++++++++++++++++++++++++++++ lib/widgets/chat_raid.dart | 6 ++- lib/widgets/chat_timeout.dart | 43 +++++++++++++++++ pubspec.lock | 8 ++++ pubspec.yaml | 1 + 8 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 lib/widgets/chat_timeout.dart diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index 71c5e1b..07e3538 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -19,7 +19,7 @@ class BasicButton extends StatelessWidget { this.disabled, }); - static text( + static Widget text( String text, { BoxDecoration? decoration, EdgeInsetsGeometry? padding, diff --git a/lib/widgets/chat.dart b/lib/widgets/chat.dart index 41c69e3..a67127e 100644 --- a/lib/widgets/chat.dart +++ b/lib/widgets/chat.dart @@ -8,6 +8,7 @@ import 'package:zap_stream_flutter/utils.dart'; import 'package:zap_stream_flutter/widgets/chat_badge.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_timeout.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'; @@ -20,14 +21,16 @@ class ChatWidget extends StatelessWidget { @override Widget build(BuildContext context) { var muteLists = [stream.info.host]; - if (ndk.accounts.getPublicKey() != null) { - muteLists.add(ndk.accounts.getPublicKey()!); + final myKey = ndk.accounts.getPublicKey(); + if (myKey != null) { + muteLists.add(myKey); } var filters = [ Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]), - Filter(kinds: [1312], limit: 200, aTags: [stream.aTag]), + Filter(kinds: [1312, 1313], limit: 200, aTags: [stream.aTag]), Filter(kinds: [Nip51List.kMute], authors: muteLists), + Filter(kinds: [1314], authors: muteLists), Filter(kinds: [8], authors: [stream.info.host]), ]; return RxFilter( @@ -35,26 +38,42 @@ class ChatWidget extends StatelessWidget { relays: stream.info.relays, filters: filters, builder: (ctx, state) { + final now = DateTime.now().millisecondsSinceEpoch / 1000; + final firstPassEvents = (state ?? []).where( + (e) => switch (e.kind) { + 1314 => muteLists.contains( + e.pubKey, + ), // filter timeouts to only people allowed to mute + // TODO: check other kinds are valid for this stream + _ => true, + }, + ); final mutedPubkeys = - (state ?? []) - .where((e) => e.kind == Nip51List.kMute) + firstPassEvents + .where( + (e) => + e.kind == Nip51List.kMute || + (e.kind == 1314 && + e.createdAt < now && + double.parse(e.getFirstTag("expiration")!) > now), + ) .map((e) => e.tags) .expand((e) => e) - .where( - (e) => e[0] == "p" && e[1] != stream.info.host, - ) // cant mute host + .where((e) => e[0] == "p") .map((e) => e[1]) .toSet(); + final isChatDisabled = mutedPubkeys.contains(myKey); final filteredChat = - (state ?? []) - .where( - (e) => - !mutedPubkeys.contains(switch (e.kind) { - 9735 => ZapReceipt.fromEvent(e).sender ?? e.pubKey, - _ => e.pubKey, - }), - ) + firstPassEvents + .where((e) { + final author = switch (e.kind) { + 9735 => ZapReceipt.fromEvent(e).sender ?? e.pubKey, + _ => e.pubKey, + }; + return muteLists.contains(author) || // cant mute self or host + !mutedPubkeys.contains(author); + }) .sortedBy((e) => e.createdAt) .reversed .toList(); @@ -105,6 +124,7 @@ class ChatWidget extends StatelessWidget { event: filteredChat[idx], stream: stream, ), + 1314 => ChatTimeoutWidget(timeout: filteredChat[idx]), 9735 => ChatZapWidget( key: Key("chat:${filteredChat[idx].id}"), stream: stream, @@ -118,8 +138,10 @@ class ChatWidget extends StatelessWidget { }, ), ), - if (stream.info.status == StreamStatus.live) + if (stream.info.status == StreamStatus.live && !isChatDisabled) WriteMessageWidget(stream: stream), + if (stream.info.status == StreamStatus.live && isChatDisabled) + _chatDisabled(filteredChat), if (stream.info.status == StreamStatus.ended) Container( padding: EdgeInsets.all(8), @@ -140,6 +162,37 @@ class ChatWidget extends StatelessWidget { }, ); } + + Widget _chatDisabled(List events) { + final myKey = ndk.accounts.getPublicKey(); + final timeoutEvent = events.firstWhereOrNull( + (e) => e.kind == 1314 && e.pTags.contains(myKey), + ); + return Container( + padding: EdgeInsets.all(12), + width: double.maxFinite, + alignment: Alignment.center, + decoration: BoxDecoration(color: WARNING), + child: Column( + children: [ + Text("CHAT DISABLED", style: TextStyle(fontWeight: FontWeight.bold)), + if (timeoutEvent != null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Timeout expires: "), + CountdownTimer( + onTrigger: () => {}, + triggerAt: DateTime.fromMillisecondsSinceEpoch( + int.parse(timeoutEvent.getFirstTag("expiration")!) * 1000, + ), + ), + ], + ), + ], + ), + ); + } } class _TopZappersWidget extends StatelessWidget { diff --git a/lib/widgets/chat_message.dart b/lib/widgets/chat_message.dart index 84150ce..0ccf415 100644 --- a/lib/widgets/chat_message.dart +++ b/lib/widgets/chat_message.dart @@ -32,7 +32,12 @@ class ChatMessageWidget extends StatelessWidget { showModalBottomSheet( context: context, constraints: BoxConstraints.expand(), - builder: (ctx) => ChatModalWidget(profile: profile, event: msg), + builder: + (ctx) => ChatModalWidget( + profile: profile, + event: msg, + stream: stream, + ), ); } }, diff --git a/lib/widgets/chat_modal.dart b/lib/widgets/chat_modal.dart index fb5dd2d..20b12e6 100644 --- a/lib/widgets/chat_modal.dart +++ b/lib/widgets/chat_modal.dart @@ -1,9 +1,13 @@ +import 'package:duration/duration.dart'; import 'package:flutter/material.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/button_follow.dart'; import 'package:zap_stream_flutter/widgets/mute_button.dart'; import 'package:zap_stream_flutter/widgets/nostr_text.dart'; +import 'package:zap_stream_flutter/widgets/pill.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; import 'package:zap_stream_flutter/widgets/reaction.dart'; import 'package:zap_stream_flutter/widgets/zap.dart'; @@ -11,11 +15,13 @@ import 'package:zap_stream_flutter/widgets/zap.dart'; class ChatModalWidget extends StatefulWidget { final Metadata profile; final Nip01Event event; + final StreamEvent stream; const ChatModalWidget({ super.key, required this.profile, required this.event, + required this.stream, }); @override @@ -24,9 +30,13 @@ class ChatModalWidget extends StatefulWidget { class _ChatModalWidget extends State { bool _showEmojiPicker = false; + bool _showTimeoutOptions = false; @override Widget build(BuildContext context) { + final isModerator = + widget.stream.info.host == ndk.accounts.getPublicKey(); + return Container( padding: EdgeInsets.fromLTRB(5, 10, 5, 0), child: Column( @@ -50,6 +60,7 @@ class _ChatModalWidget extends State { ), onPressed: () => setState(() { + _showTimeoutOptions = false; _showEmojiPicker = !_showEmojiPicker; }), icon: Icon(Icons.mood), @@ -76,9 +87,78 @@ class _ChatModalWidget extends State { }, icon: Icon(Icons.bolt), ), + if (isModerator) + IconButton( + color: WARNING, + style: ButtonStyle( + backgroundColor: WidgetStateColor.resolveWith( + (_) => LAYER_3, + ), + ), + onPressed: + () => setState(() { + _showEmojiPicker = false; + _showTimeoutOptions = !_showTimeoutOptions; + }), + icon: Icon(Icons.timer_outlined), + ), ], ), if (_showEmojiPicker) ReactionWidget(event: widget.event), + + if (_showTimeoutOptions) + GridView.count( + shrinkWrap: true, + crossAxisCount: 5, + childAspectRatio: 3, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + children: + [ + 10, + 30, + 60, + 300, + 60 * 10, + 60 * 30, + 60 * 60, + 60 * 60 * 6, + 60 * 60 * 12, + 60 * 60 * 24, + 60 * 60 * 24 * 2, + 60 * 60 * 24 * 7, + 60 * 60 * 24 * 7 * 2, + 60 * 60 * 24 * 7 * 3, + 60 * 60 * 24 * 7 * 4, + ] + .map( + (v) => PillWidget( + color: LAYER_2, + onTap: () { + final now = + (DateTime.now().millisecondsSinceEpoch / 1000) + .ceil(); + final timeout = Nip01Event( + pubKey: ndk.accounts.getPublicKey()!, + kind: 1314, + createdAt: now, + tags: [ + ["p", widget.event.pubKey], + ["expiration", (now + v).toString()], + ], + content: "", + ); + ndk.broadcast.broadcast(nostrEvent: timeout); + Navigator.pop(context); + }, + child: Text( + Duration(seconds: v).pretty(abbreviated: true), + textAlign: TextAlign.center, + ), + ), + ) + .toList(), + ), FollowButton( pubkey: widget.event.pubKey, onTap: () { diff --git a/lib/widgets/chat_raid.dart b/lib/widgets/chat_raid.dart index 71db150..1bab7b3 100644 --- a/lib/widgets/chat_raid.dart +++ b/lib/widgets/chat_raid.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:duration/duration.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:ndk/ndk.dart'; @@ -202,7 +203,10 @@ class _CountdownTimerState extends State animation: _animation, builder: (context, child) { final secondsLeft = _animation.value.ceil(); - return Text(secondsLeft.toString(), style: widget.style); + return Text( + Duration(seconds: secondsLeft).pretty(abbreviated: true), + style: widget.style, + ); }, ); } diff --git a/lib/widgets/chat_timeout.dart b/lib/widgets/chat_timeout.dart new file mode 100644 index 0000000..ca46902 --- /dev/null +++ b/lib/widgets/chat_timeout.dart @@ -0,0 +1,43 @@ +import 'package:duration/duration.dart'; +import 'package:flutter/widgets.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/widgets/profile.dart'; + +class ChatTimeoutWidget extends StatelessWidget { + final Nip01Event timeout; + + const ChatTimeoutWidget({super.key, required this.timeout}); + + @override + Widget build(BuildContext context) { + final pTags = timeout.pTags; + final duration = + double.parse(timeout.getFirstTag("expiration")!) - timeout.createdAt; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: RichText( + text: TextSpan( + style: TextStyle(color: LAYER_5), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ProfileNameWidget.pubkey(timeout.pubKey), + ), + TextSpan(text: " timed out "), + ...pTags.map( + (p) => WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ProfileNameWidget.pubkey(p), + ), + ), + TextSpan( + text: " for ${Duration(seconds: duration.toInt()).pretty()}", + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 40fec1b..a745b65 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + duration: + dependency: "direct main" + description: + name: duration + sha256: "13e5d20723c9c1dde8fb318cf86716d10ce294734e81e44ae1a817f3ae714501" + url: "https://pub.dev" + source: hosted + version: "4.0.3" elliptic: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1fe23d7..8f001a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: intl: ^0.20.2 flutter_markdown_plus: ^1.0.3 share_plus: ^11.0.0 + duration: ^4.0.3 dependency_overrides: ndk: