mirror of
https://github.com/nostrlabs-io/zap-stream-flutter.git
synced 2025-06-15 19:48:23 +00:00
Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
befd7c014b
|
|||
0a75665bde
|
|||
46b809ff58
|
|||
8f41ed6d0e
|
@ -9,8 +9,10 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:ndk/ndk.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zap_stream_flutter/const.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:zap_stream_flutter/utils.dart';
|
||||
|
||||
class Notepush {
|
||||
final String base;
|
||||
@ -158,9 +160,17 @@ Future<void> setupNotifications() async {
|
||||
}
|
||||
});
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((msg) {
|
||||
final notification = msg.notification;
|
||||
if (notification != null) {
|
||||
// TODO: redirect to stream
|
||||
try {
|
||||
final notification = msg.notification;
|
||||
final String? json = msg.data["nostr_event"];
|
||||
if (notification != null && json != null) {
|
||||
// Just launch the URL because we support deep links
|
||||
final event = Nip01Event.fromJson(JsonCodec().decode(json));
|
||||
final stream = StreamEvent(event);
|
||||
launchUrl(Uri.parse("https://zap.stream/${stream.link}"));
|
||||
}
|
||||
} catch (e) {
|
||||
developer.log("Failed to process push notification\n ${e.toString()}");
|
||||
}
|
||||
});
|
||||
await fbase.setAutoInitEnabled(true);
|
||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:ndk/ndk.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
@ -16,11 +17,11 @@ class RxFilter<T> extends StatefulWidget {
|
||||
final List<String>? relays;
|
||||
|
||||
const RxFilter(
|
||||
Key key, {
|
||||
Key? key, {
|
||||
required this.filters,
|
||||
this.leaveOpen = false,
|
||||
required this.builder,
|
||||
this.mapper,
|
||||
this.leaveOpen = true,
|
||||
this.relays,
|
||||
}) : super(key: key);
|
||||
|
||||
@ -29,22 +30,79 @@ class RxFilter<T> extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RxFilter<T> extends State<RxFilter<T>> {
|
||||
late NdkResponse _response;
|
||||
late StreamSubscription _listener;
|
||||
HashMap<String, (int, T)>? _events;
|
||||
late final RxFilterState<T> _state;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
developer.log("RX:SEDNING ${widget.filters}");
|
||||
_response = ndk.requests.subscription(
|
||||
_state = RxFilterState<T>(
|
||||
filters: widget.filters,
|
||||
explicitRelays: widget.relays,
|
||||
cacheWrite: true
|
||||
leaveOpen: widget.leaveOpen,
|
||||
mapper: widget.mapper,
|
||||
relays: widget.relays,
|
||||
);
|
||||
if (!widget.leaveOpen) {
|
||||
/* send spam into chat
|
||||
if (widget.key is ValueKey) {
|
||||
final vk = (widget.key as ValueKey).value as String;
|
||||
if (vk.startsWith("stream:chat:")) {
|
||||
Timer.periodic(Duration(seconds: 1), (_) {
|
||||
final spam = Nip01Event(
|
||||
pubKey:
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
|
||||
kind: 1311,
|
||||
tags: [
|
||||
["a", vk.split(":").last],
|
||||
],
|
||||
content: "SPAM ${DateTime.now()}",
|
||||
);
|
||||
_state.insertEvent(spam);
|
||||
});
|
||||
}
|
||||
}*/
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_state.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _state,
|
||||
builder: (context, state, _) {
|
||||
return widget.builder(context, state);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RxFilterState<T> extends ChangeNotifier
|
||||
implements ValueListenable<List<T>?> {
|
||||
final List<Filter> filters;
|
||||
final bool leaveOpen;
|
||||
final T Function(Nip01Event)? mapper;
|
||||
final List<String>? relays;
|
||||
HashMap<String, (int, T)>? _events;
|
||||
late final NdkResponse _response;
|
||||
late final StreamSubscription _listener;
|
||||
|
||||
RxFilterState({
|
||||
required this.filters,
|
||||
this.leaveOpen = false,
|
||||
this.mapper,
|
||||
this.relays,
|
||||
}) {
|
||||
developer.log("RX:SEDNING $filters");
|
||||
_response = ndk.requests.subscription(
|
||||
filters: filters,
|
||||
explicitRelays: relays,
|
||||
cacheWrite: true,
|
||||
);
|
||||
if (!leaveOpen) {
|
||||
_response.future.then((_) {
|
||||
developer.log("RX:CLOSING ${widget.filters}");
|
||||
developer.log("RX:CLOSING $filters");
|
||||
ndk.requests.closeSubscription(_response.requestId);
|
||||
});
|
||||
}
|
||||
@ -55,27 +113,34 @@ class _RxFilter<T> extends State<RxFilter<T>> {
|
||||
developer.log("RX:ERROR $e");
|
||||
})
|
||||
.listen((events) {
|
||||
if (context.mounted) {
|
||||
setState(() {
|
||||
developer.log(
|
||||
"RX:GOT ${events.length} events for ${widget.filters}",
|
||||
);
|
||||
events.forEach(_replaceInto);
|
||||
});
|
||||
developer.log("RX:GOT ${events.length} events for $filters");
|
||||
var didUpdate = false;
|
||||
for (final ev in events) {
|
||||
if (_replaceInto(ev)) {
|
||||
didUpdate = true;
|
||||
}
|
||||
}
|
||||
if (didUpdate) {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _replaceInto(Nip01Event ev) {
|
||||
void insertEvent(Nip01Event ev) {
|
||||
if (_replaceInto(ev)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool _replaceInto(Nip01Event ev) {
|
||||
final evKey = _eventKey(ev);
|
||||
_events ??= HashMap();
|
||||
final existing = _events![evKey];
|
||||
if (existing == null || existing.$1 < ev.createdAt) {
|
||||
_events![evKey] = (
|
||||
ev.createdAt,
|
||||
widget.mapper != null ? widget.mapper!(ev) : ev as T,
|
||||
);
|
||||
_events![evKey] = (ev.createdAt, mapper != null ? mapper!(ev) : ev as T);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _eventKey(Nip01Event ev) {
|
||||
@ -89,17 +154,15 @@ class _RxFilter<T> extends State<RxFilter<T>> {
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
developer.log("RX:CLOSING ${widget.filters}");
|
||||
_listener.cancel();
|
||||
ndk.requests.closeSubscription(_response.requestId);
|
||||
}
|
||||
List<T>? get value =>
|
||||
_events != null ? List<T>.from(_events!.values.map((v) => v.$2)) : null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.builder(context, _events?.values.map((v) => v.$2).toList());
|
||||
void dispose() {
|
||||
developer.log("RX:CLOSING $filters");
|
||||
_listener.cancel();
|
||||
ndk.requests.closeSubscription(_response.requestId);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,6 +20,7 @@ class ChatWidget extends StatelessWidget {
|
||||
final StreamEvent stream;
|
||||
|
||||
const ChatWidget({super.key, required this.stream});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var moderators = [stream.info.host];
|
||||
@ -112,40 +113,32 @@ class ChatWidget extends StatelessWidget {
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
primary: true,
|
||||
itemCount: filteredChat.length,
|
||||
itemBuilder:
|
||||
(ctx, idx) => switch (filteredChat[idx].kind) {
|
||||
1311 => ChatMessageWidget(
|
||||
key: Key("chat:${filteredChat[idx].id}"),
|
||||
stream: stream,
|
||||
msg: filteredChat[idx],
|
||||
badges:
|
||||
badgeAwards[filteredChat[idx].pubKey]
|
||||
?.map(
|
||||
(a) => ChatBadgeWidget.fromATag(
|
||||
a,
|
||||
key: Key("${filteredChat[idx].pubKey}:$a"),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
1312 => ChatRaidMessage(
|
||||
event: filteredChat[idx],
|
||||
stream: stream,
|
||||
),
|
||||
1314 => ChatTimeoutWidget(timeout: filteredChat[idx]),
|
||||
9735 => ChatZapWidget(
|
||||
key: Key("chat:${filteredChat[idx].id}"),
|
||||
stream: stream,
|
||||
zap: filteredChat[idx],
|
||||
),
|
||||
8 => ChatBadgeAwardWidget(
|
||||
event: filteredChat[idx],
|
||||
stream: stream,
|
||||
),
|
||||
_ => SizedBox(),
|
||||
},
|
||||
itemBuilder: (ctx, idx) {
|
||||
final msg = filteredChat[idx];
|
||||
final widget = switch (msg.kind) {
|
||||
1311 => ChatMessageWidget(
|
||||
stream: stream,
|
||||
msg: msg,
|
||||
badges:
|
||||
badgeAwards[msg.pubKey]
|
||||
?.map(
|
||||
(a) => ChatBadgeWidget.fromATag(
|
||||
a,
|
||||
key: Key("${msg.pubKey}:$a"),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
1312 => ChatRaidMessage(event: msg, stream: stream),
|
||||
1314 => ChatTimeoutWidget(timeout: msg),
|
||||
9735 => ChatZapWidget(stream: stream, zap: msg),
|
||||
8 => ChatBadgeAwardWidget(event: msg, stream: stream),
|
||||
_ => SizedBox(),
|
||||
};
|
||||
|
||||
return widget;
|
||||
},
|
||||
),
|
||||
),
|
||||
if (stream.info.status == StreamStatus.live && !isChatDisabled)
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ndk/ndk.dart';
|
||||
import 'package:zap_stream_flutter/const.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';
|
||||
@ -24,33 +23,47 @@ class ChatMessageWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProfileLoaderWidget(msg.pubKey, (ctx, state) {
|
||||
final profile = state.data ?? Metadata(pubKey: msg.pubKey);
|
||||
return GestureDetector(
|
||||
onLongPress: () {
|
||||
if (ndk.accounts.canSign) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
constraints: BoxConstraints.expand(),
|
||||
builder:
|
||||
(ctx) => ChatModalWidget(
|
||||
profile: profile,
|
||||
event: msg,
|
||||
stream: stream,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2, vertical: 4),
|
||||
child: Column(
|
||||
spacing: 2,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [_chatText(profile), ChatReactions(msg: msg)],
|
||||
return FutureBuilder(
|
||||
future: ndk.metadata.loadMetadata(msg.pubKey),
|
||||
builder: (ctx, state) {
|
||||
final profile = state.data ?? Metadata(pubKey: msg.pubKey);
|
||||
return GestureDetector(
|
||||
onLongPress: () {
|
||||
if (ndk.accounts.canSign) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
constraints: BoxConstraints.expand(),
|
||||
builder:
|
||||
(ctx) => ChatModalWidget(
|
||||
profile: profile,
|
||||
event: msg,
|
||||
stream: stream,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2, vertical: 4),
|
||||
child: Column(
|
||||
spacing: 2,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_chatText(profile),
|
||||
/*RxFilter<Nip01Event>(
|
||||
Key(msg.id),
|
||||
filters: [
|
||||
Filter(kinds: [9735, 7], eTags: [msg.id]),
|
||||
],
|
||||
builder: (context, state) {
|
||||
return ChatReactions(msg: msg, events: state ?? []);
|
||||
},
|
||||
),*/
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, key: Key("chat:${msg.id}:profile"));
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chatText(Metadata profile) {
|
||||
@ -91,25 +104,15 @@ class ChatMessageWidget extends StatelessWidget {
|
||||
|
||||
class ChatReactions extends StatelessWidget {
|
||||
final Nip01Event msg;
|
||||
final List<Nip01Event> events;
|
||||
|
||||
const ChatReactions({super.key, required this.msg});
|
||||
const ChatReactions({super.key, required this.msg, required this.events});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RxFilter<Nip01Event>(
|
||||
Key("chat:${msg.id}:reactions"),
|
||||
filters: [
|
||||
Filter(kinds: [9735, 7], eTags: [msg.id]),
|
||||
],
|
||||
builder: (ctx, data) => _chatReactions(ctx, data),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chatReactions(BuildContext context, List<Nip01Event>? events) {
|
||||
if ((events?.length ?? 0) == 0) return SizedBox.shrink();
|
||||
|
||||
// reactions must have e tag pointing to msg
|
||||
final filteredEvents = events!.where((e) => e.getEId() == msg.id);
|
||||
final filteredEvents = events.where((e) => e.getEId() == msg.id);
|
||||
if (filteredEvents.isEmpty) return SizedBox.shrink();
|
||||
final zaps = filteredEvents
|
||||
.where((e) => e.kind == 9735)
|
||||
.map((e) => ZapReceipt.fromEvent(e));
|
||||
|
@ -87,24 +87,28 @@ class StreamInfoWidget extends StatelessWidget {
|
||||
),
|
||||
|
||||
if (stream.info.tags.isNotEmpty || stream.info.gameInfo != null)
|
||||
Row(
|
||||
spacing: 2,
|
||||
children: [
|
||||
if (stream.info.gameInfo != null)
|
||||
GameInfoWidget(info: stream.info.gameInfo!),
|
||||
...stream.info.tags.map(
|
||||
(t) => PillWidget(
|
||||
color: LAYER_2,
|
||||
onTap: () {
|
||||
context.push("/t/${Uri.encodeComponent(t)}");
|
||||
},
|
||||
child: Text(
|
||||
t,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
primary: false,
|
||||
child: Row(
|
||||
spacing: 2,
|
||||
children: [
|
||||
if (stream.info.gameInfo != null)
|
||||
GameInfoWidget(info: stream.info.gameInfo!),
|
||||
...stream.info.tags.map(
|
||||
(t) => PillWidget(
|
||||
color: LAYER_2,
|
||||
onTap: () {
|
||||
context.push("/t/${Uri.encodeComponent(t)}");
|
||||
},
|
||||
child: Text(
|
||||
t,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
StreamCardsWidget(stream: stream),
|
||||
],
|
||||
|
@ -1,7 +1,7 @@
|
||||
name: zap_stream_flutter
|
||||
description: "zap.stream"
|
||||
publish_to: "none"
|
||||
version: 0.8.0+10
|
||||
version: 0.8.1+11
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.2
|
||||
|
Reference in New Issue
Block a user