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,
});
static text(
static Widget text(
String text, {
BoxDecoration? decoration,
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_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<Nip01Event>(
@ -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) {
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<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 {

View File

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

View File

@ -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<ChatModalWidget> {
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<ChatModalWidget> {
),
onPressed:
() => setState(() {
_showTimeoutOptions = false;
_showEmojiPicker = !_showEmojiPicker;
}),
icon: Icon(Icons.mood),
@ -76,9 +87,78 @@ class _ChatModalWidget extends State<ChatModalWidget> {
},
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: () {

View File

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

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"
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:

View File

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