feat: timeouts

closes #13
This commit is contained in:
2025-05-19 16:32:21 +01:00
parent c865f24b2c
commit a5736aa3d3
8 changed files with 214 additions and 20 deletions

View File

@ -19,7 +19,7 @@ class BasicButton extends StatelessWidget {
this.disabled, this.disabled,
}); });
static text( static Widget text(
String text, { String text, {
BoxDecoration? decoration, BoxDecoration? decoration,
EdgeInsetsGeometry? padding, EdgeInsetsGeometry? padding,

View File

@ -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_badge.dart';
import 'package:zap_stream_flutter/widgets/chat_message.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_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_write.dart';
import 'package:zap_stream_flutter/widgets/chat_zap.dart'; import 'package:zap_stream_flutter/widgets/chat_zap.dart';
import 'package:zap_stream_flutter/widgets/goal.dart'; import 'package:zap_stream_flutter/widgets/goal.dart';
@ -20,14 +21,16 @@ class ChatWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var muteLists = [stream.info.host]; var muteLists = [stream.info.host];
if (ndk.accounts.getPublicKey() != null) { final myKey = ndk.accounts.getPublicKey();
muteLists.add(ndk.accounts.getPublicKey()!); if (myKey != null) {
muteLists.add(myKey);
} }
var filters = [ var filters = [
Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]), 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: [Nip51List.kMute], authors: muteLists),
Filter(kinds: [1314], authors: muteLists),
Filter(kinds: [8], authors: [stream.info.host]), Filter(kinds: [8], authors: [stream.info.host]),
]; ];
return RxFilter<Nip01Event>( return RxFilter<Nip01Event>(
@ -35,26 +38,42 @@ class ChatWidget extends StatelessWidget {
relays: stream.info.relays, relays: stream.info.relays,
filters: filters, filters: filters,
builder: (ctx, state) { 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 = final mutedPubkeys =
(state ?? []) firstPassEvents
.where((e) => e.kind == Nip51List.kMute) .where(
(e) =>
e.kind == Nip51List.kMute ||
(e.kind == 1314 &&
e.createdAt < now &&
double.parse(e.getFirstTag("expiration")!) > now),
)
.map((e) => e.tags) .map((e) => e.tags)
.expand((e) => e) .expand((e) => e)
.where( .where((e) => e[0] == "p")
(e) => e[0] == "p" && e[1] != stream.info.host,
) // cant mute host
.map((e) => e[1]) .map((e) => e[1])
.toSet(); .toSet();
final isChatDisabled = mutedPubkeys.contains(myKey);
final filteredChat = final filteredChat =
(state ?? []) firstPassEvents
.where( .where((e) {
(e) => final author = switch (e.kind) {
!mutedPubkeys.contains(switch (e.kind) {
9735 => ZapReceipt.fromEvent(e).sender ?? e.pubKey, 9735 => ZapReceipt.fromEvent(e).sender ?? e.pubKey,
_ => e.pubKey, _ => e.pubKey,
}), };
) return muteLists.contains(author) || // cant mute self or host
!mutedPubkeys.contains(author);
})
.sortedBy((e) => e.createdAt) .sortedBy((e) => e.createdAt)
.reversed .reversed
.toList(); .toList();
@ -105,6 +124,7 @@ class ChatWidget extends StatelessWidget {
event: filteredChat[idx], event: filteredChat[idx],
stream: stream, stream: stream,
), ),
1314 => ChatTimeoutWidget(timeout: filteredChat[idx]),
9735 => ChatZapWidget( 9735 => ChatZapWidget(
key: Key("chat:${filteredChat[idx].id}"), key: Key("chat:${filteredChat[idx].id}"),
stream: stream, 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), WriteMessageWidget(stream: stream),
if (stream.info.status == StreamStatus.live && isChatDisabled)
_chatDisabled(filteredChat),
if (stream.info.status == StreamStatus.ended) if (stream.info.status == StreamStatus.ended)
Container( Container(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
@ -140,6 +162,37 @@ class ChatWidget extends StatelessWidget {
}, },
); );
} }
Widget _chatDisabled(List<Nip01Event> 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 { class _TopZappersWidget extends StatelessWidget {

View File

@ -32,7 +32,12 @@ class ChatMessageWidget extends StatelessWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
constraints: BoxConstraints.expand(), constraints: BoxConstraints.expand(),
builder: (ctx) => ChatModalWidget(profile: profile, event: msg), builder:
(ctx) => ChatModalWidget(
profile: profile,
event: msg,
stream: stream,
),
); );
} }
}, },

View File

@ -1,9 +1,13 @@
import 'package:duration/duration.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:ndk/ndk.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/theme.dart';
import 'package:zap_stream_flutter/utils.dart';
import 'package:zap_stream_flutter/widgets/button_follow.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/mute_button.dart';
import 'package:zap_stream_flutter/widgets/nostr_text.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/profile.dart';
import 'package:zap_stream_flutter/widgets/reaction.dart'; import 'package:zap_stream_flutter/widgets/reaction.dart';
import 'package:zap_stream_flutter/widgets/zap.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 { class ChatModalWidget extends StatefulWidget {
final Metadata profile; final Metadata profile;
final Nip01Event event; final Nip01Event event;
final StreamEvent stream;
const ChatModalWidget({ const ChatModalWidget({
super.key, super.key,
required this.profile, required this.profile,
required this.event, required this.event,
required this.stream,
}); });
@override @override
@ -24,9 +30,13 @@ class ChatModalWidget extends StatefulWidget {
class _ChatModalWidget extends State<ChatModalWidget> { class _ChatModalWidget extends State<ChatModalWidget> {
bool _showEmojiPicker = false; bool _showEmojiPicker = false;
bool _showTimeoutOptions = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isModerator =
widget.stream.info.host == ndk.accounts.getPublicKey();
return Container( return Container(
padding: EdgeInsets.fromLTRB(5, 10, 5, 0), padding: EdgeInsets.fromLTRB(5, 10, 5, 0),
child: Column( child: Column(
@ -50,6 +60,7 @@ class _ChatModalWidget extends State<ChatModalWidget> {
), ),
onPressed: onPressed:
() => setState(() { () => setState(() {
_showTimeoutOptions = false;
_showEmojiPicker = !_showEmojiPicker; _showEmojiPicker = !_showEmojiPicker;
}), }),
icon: Icon(Icons.mood), icon: Icon(Icons.mood),
@ -76,9 +87,78 @@ class _ChatModalWidget extends State<ChatModalWidget> {
}, },
icon: Icon(Icons.bolt), 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 (_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( FollowButton(
pubkey: widget.event.pubKey, pubkey: widget.event.pubKey,
onTap: () { onTap: () {

View File

@ -1,4 +1,5 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:duration/duration.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:ndk/ndk.dart'; import 'package:ndk/ndk.dart';
@ -202,7 +203,10 @@ class _CountdownTimerState extends State<CountdownTimer>
animation: _animation, animation: _animation,
builder: (context, child) { builder: (context, child) {
final secondsLeft = _animation.value.ceil(); final secondsLeft = _animation.value.ceil();
return Text(secondsLeft.toString(), style: widget.style); return Text(
Duration(seconds: secondsLeft).pretty(abbreviated: true),
style: widget.style,
);
}, },
); );
} }

View File

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

View File

@ -177,6 +177,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.11"
duration:
dependency: "direct main"
description:
name: duration
sha256: "13e5d20723c9c1dde8fb318cf86716d10ce294734e81e44ae1a817f3ae714501"
url: "https://pub.dev"
source: hosted
version: "4.0.3"
elliptic: elliptic:
dependency: transitive dependency: transitive
description: description:

View File

@ -34,6 +34,7 @@ dependencies:
intl: ^0.20.2 intl: ^0.20.2
flutter_markdown_plus: ^1.0.3 flutter_markdown_plus: ^1.0.3
share_plus: ^11.0.0 share_plus: ^11.0.0
duration: ^4.0.3
dependency_overrides: dependency_overrides:
ndk: ndk: