Work on implementing search filter in app [WIP]

This commit is contained in:
Mike Schwörer 2024-09-19 19:46:46 +02:00
parent 9d35916280
commit 3adeadf6fb
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
23 changed files with 898 additions and 48 deletions

View File

@ -27,6 +27,26 @@ enum ChannelSelector {
final String apiKey; final String apiKey;
} }
class MessageFilter {
List<String>? channelIDs;
String? searchFilter;
List<String>? senderNames;
List<String>? usedKeys;
List<int>? priority;
DateTime? timeBefore;
DateTime? timeAfter;
MessageFilter({
this.channelIDs,
this.searchFilter,
this.senderNames,
this.usedKeys,
this.priority,
this.timeBefore,
this.timeAfter,
});
}
class APIClient { class APIClient {
static const String _base = 'https://simplecloudnotifier.de/api/v2'; static const String _base = 'https://simplecloudnotifier.de/api/v2';
@ -226,7 +246,7 @@ class APIClient {
); );
} }
static Future<(String, List<SCNMessage>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List<String>? channelIDs}) async { static Future<(String, List<SCNMessage>)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, MessageFilter? filter}) async {
return await _request( return await _request(
name: 'getMessageList', name: 'getMessageList',
method: 'GET', method: 'GET',
@ -234,7 +254,12 @@ class APIClient {
query: { query: {
'next_page_token': pageToken, 'next_page_token': pageToken,
if (pageSize != null) 'page_size': pageSize.toString(), if (pageSize != null) 'page_size': pageSize.toString(),
if (channelIDs != null) 'channel_id': channelIDs.join(","), if (filter?.channelIDs != null) 'channel_id': filter!.channelIDs!.join(","),
if (filter?.senderNames != null) 'sender': filter!.senderNames!.join(","),
if (filter?.timeBefore != null) 'before': filter!.timeBefore!.toIso8601String(),
if (filter?.timeAfter != null) 'after': filter!.timeAfter!.toIso8601String(),
if (filter?.priority != null) 'priority': filter!.priority!.map((p) => p.toString()).join(","),
if (filter?.usedKeys != null) 'used_key': filter!.usedKeys!.join(","),
}, },
fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'), fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'),
authToken: auth.getToken(), authToken: auth.getToken(),
@ -339,4 +364,8 @@ class APIClient {
authToken: token, authToken: token,
); );
} }
static Future<List<String>> getSenderNameList(AppAuth userAcc) {
return Future.value(['TODO']); //TODO
}
} }

View File

@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_filter_dialog.dart'; import 'package:simplecloudnotifier/components/layout/app_bar_filter_dialog.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart'; import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart';
import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/pages/debug/debug_main.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_events.dart';
@ -108,7 +109,8 @@ class _SCNAppBarState extends State<SCNAppBar> {
icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass),
onPressed: () { onPressed: () {
value.setShowSearchField(false); value.setShowSearchField(false);
AppEvents().notifySearchListeners(_ctrlSearchField.text); final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search);
AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]);
_ctrlSearchField.clear(); _ctrlSearchField.clear();
}, },
), ),
@ -157,7 +159,8 @@ class _SCNAppBarState extends State<SCNAppBar> {
), ),
onSubmitted: (value) { onSubmitted: (value) {
AppBarState().setShowSearchField(false); AppBarState().setShowSearchField(false);
AppEvents().notifySearchListeners(_ctrlSearchField.text); final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search);
AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]);
_ctrlSearchField.clear(); _ctrlSearchField.clear();
}, },
); );

View File

@ -1,5 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_channel.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_keytoken.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_priority.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_sendername.dart';
import 'package:simplecloudnotifier/components/modals/filter_modal_time.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
class AppBarFilterDialog extends StatefulWidget { class AppBarFilterDialog extends StatefulWidget {
@ -48,17 +54,17 @@ class _AppBarFilterDialogState extends State<AppBarFilterDialog> {
child: Column( child: Column(
children: [ children: [
SizedBox(height: 4), SizedBox(height: 4),
_buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search'), _buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search', _showSearch),
Divider(), Divider(),
_buildFilterItem(context, FontAwesomeIcons.snake, 'Channel'), _buildFilterItem(context, FontAwesomeIcons.snake, 'Channel', _showChannelModal),
Divider(), Divider(),
_buildFilterItem(context, FontAwesomeIcons.signature, 'Sender'), _buildFilterItem(context, FontAwesomeIcons.signature, 'Sender', _showSenderModal),
Divider(), Divider(),
_buildFilterItem(context, FontAwesomeIcons.timer, 'Time'), _buildFilterItem(context, FontAwesomeIcons.timer, 'Time', _showTimeModal),
Divider(), Divider(),
_buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority'), _buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority', _showPriorityModal),
Divider(), Divider(),
_buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key'), _buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key', _showKeytokenModal),
SizedBox(height: 4), SizedBox(height: 4),
], ],
), ),
@ -72,15 +78,39 @@ class _AppBarFilterDialogState extends State<AppBarFilterDialog> {
); );
} }
Widget _buildFilterItem(BuildContext context, IconData icon, String label) { Widget _buildFilterItem(BuildContext context, IconData icon, String label, void Function(BuildContext context) action) {
return ListTile( return ListTile(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
title: Text(label), title: Text(label),
leading: Icon(icon), leading: Icon(icon),
onTap: () { onTap: () {
Navi.popDialog(context); Navi.popDialog(context);
//TOOD show more... action(context);
}, },
); );
} }
void _showSearch(BuildContext context) {
AppBarState().setShowSearchField(true);
}
void _showPriorityModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalPriority());
}
void _showChannelModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalChannel());
}
void _showSenderModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalSendername());
}
void _showKeytokenModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalKeytoken());
}
void _showTimeModal(BuildContext context) {
showDialog<void>(context: context, builder: (BuildContext context) => FilterModalTime());
}
} }

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalChannel extends StatefulWidget {
@override
_FilterModalChannelState createState() => _FilterModalChannelState();
}
class _FilterModalChannelState extends State<FilterModalChannel> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<Channel>>? _futureChannels;
@override
void initState() {
super.initState();
_futureChannels = null;
_futureChannels = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all);
return channels.where((p) => p.subscription?.confirmed ?? false).map((e) => e.channel).toList(); // return only subscribed channels
}());
}
void toggleEntry(String channelID) {
setState(() {
if (_selectedEntries.contains(channelID)) {
_selectedEntries.remove(channelID);
} else {
_selectedEntries.add(channelID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Channels'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureChannels == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureChannels!.future,
builder: ((context, snapshot) {
if (_futureChannels?.value != null) {
return _buildList(context, _futureChannels!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: _futureChannels?.get()?.map((e) => e as Channel?).firstWhere((p) => p?.channelID == e, orElse: () => null)?.displayName ?? '???',
value: e,
type: MessageFilterChipletType.channel,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.channel], chiplets);
}
Widget _buildList(BuildContext context, List<Channel> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final channel = list[index];
return ListTile(
title: Text(channel.displayName),
leading: Icon(_selectedEntries.contains(channel.channelID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(channel.channelID),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/keytoken.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalKeytoken extends StatefulWidget {
@override
_FilterModalKeytokenState createState() => _FilterModalKeytokenState();
}
class _FilterModalKeytokenState extends State<FilterModalKeytoken> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<KeyToken>>? _futureKeyTokens;
@override
void initState() {
super.initState();
_futureKeyTokens = null;
_futureKeyTokens = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final toks = await APIClient.getKeyTokenList(userAcc);
return toks;
}());
}
void toggleEntry(String senderID) {
setState(() {
if (_selectedEntries.contains(senderID)) {
_selectedEntries.remove(senderID);
} else {
_selectedEntries.add(senderID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Senders'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureKeyTokens == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureKeyTokens!.future,
builder: ((context, snapshot) {
if (_futureKeyTokens?.value != null) {
return _buildList(context, _futureKeyTokens!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: _futureKeyTokens?.get()?.map((e) => e as KeyToken?).firstWhere((p) => p?.keytokenID == e, orElse: () => null)?.name ?? '???',
value: e,
type: MessageFilterChipletType.sender,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets);
}
Widget _buildList(BuildContext context, List<KeyToken> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final sender = list[index];
return ListTile(
title: Text(sender.name),
leading: Icon(_selectedEntries.contains(sender.keytokenID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(sender.keytokenID),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
class FilterModalPriority extends StatefulWidget {
@override
_FilterModalPriorityState createState() => _FilterModalPriorityState();
}
class _FilterModalPriorityState extends State<FilterModalPriority> {
Set<int> _selectedEntries = {};
Map<int, (String, String)> _texts = {
0: ('Low (0)', 'Low'),
1: ('Normal (1)', 'Normal'),
2: ('High (2)', 'High'),
};
void toggleEntry(int entry) {
setState(() {
if (_selectedEntries.contains(entry)) {
_selectedEntries.remove(entry);
} else {
_selectedEntries.add(entry);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Priority'),
content: Container(
width: 0,
height: 200,
child: ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
return ListTile(
title: Text(_texts[index]?.$1 ?? '???'),
leading: Icon(_selectedEntries.contains(index) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(index),
);
},
itemCount: 3,
),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries.map((e) => MessageFilterChiplet(label: _texts[e]?.$2 ?? '???', value: e, type: MessageFilterChipletType.priority)).toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.priority], chiplets);
}
}

View File

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalSendername extends StatefulWidget {
@override
_FilterModalSendernameState createState() => _FilterModalSendernameState();
}
class _FilterModalSendernameState extends State<FilterModalSendername> {
Set<String> _selectedEntries = {};
late ImmediateFuture<List<String>>? _futureSenders;
@override
void initState() {
super.initState();
_futureSenders = null;
_futureSenders = ImmediateFuture.ofFuture(() async {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (!userAcc.isAuth()) throw new Exception('not logged in');
final senders = await APIClient.getSenderNameList(userAcc);
return senders;
}());
}
void toggleEntry(String senderID) {
setState(() {
if (_selectedEntries.contains(senderID)) {
_selectedEntries.remove(senderID);
} else {
_selectedEntries.add(senderID);
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Senders'),
content: Container(
width: 9000,
height: 9000,
child: () {
if (_futureSenders == null) {
return Center(child: CircularProgressIndicator());
}
return FutureBuilder(
future: _futureSenders!.future,
builder: ((context, snapshot) {
if (_futureSenders?.value != null) {
return _buildList(context, _futureSenders!.value!);
} else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) {
return Text('Error: ${snapshot.error}'); //TODO better error display
} else if (snapshot.connectionState == ConnectionState.done) {
return _buildList(context, snapshot.data!);
} else {
return Center(child: CircularProgressIndicator());
}
}),
);
}(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
final chiplets = _selectedEntries
.map((e) => MessageFilterChiplet(
label: e,
value: e,
type: MessageFilterChipletType.sender,
))
.toList();
AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets);
}
Widget _buildList(BuildContext context, List<String> list) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (builder, index) {
final sender = list[index];
return ListTile(
title: Text(sender),
leading: Icon(_selectedEntries.contains(sender) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor),
onTap: () => toggleEntry(sender),
visualDensity: VisualDensity(vertical: -4),
);
},
itemCount: list.length,
);
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/state/app_events.dart';
import 'package:simplecloudnotifier/types/immediate_future.dart';
class FilterModalTime extends StatefulWidget {
@override
_FilterModalTimeState createState() => _FilterModalTimeState();
}
class _FilterModalTimeState extends State<FilterModalTime> {
DateTime? _tsBefore = null;
DateTime? _tsAfter = null;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Timerange'),
content: Container(
width: 9000,
height: 9000,
child: Placeholder(),
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge),
child: const Text('Apply'),
onPressed: () {
onOkay();
},
),
],
);
}
void onOkay() {
Navigator.of(context).pop();
//TODO
}
}

View File

@ -359,7 +359,7 @@ Future<void> _receiveMessage(RemoteMessage message, bool foreground) async {
SCNDataCache().addToMessageCache([msg]); SCNDataCache().addToMessageCache([msg]);
if (foreground) AppEvents().notifyMessageReceivedListeners(msg); if (foreground) AppEvents().notifyMessageReceivedListeners(msg);
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to query+persist message' + exc.toString(), trace: trace); ApplicationLog.error('Failed to query+persist message: ' + exc.toString(), trace: trace);
return; return;
} }
} }

View File

@ -12,11 +12,11 @@ class Channel extends HiveObject implements FieldDebuggable {
@HiveField(10) @HiveField(10)
final String ownerUserID; final String ownerUserID;
@HiveField(11) @HiveField(11)
final String internalName; final String internalName; // = InternalName, used for sending, normalized, cannot be changed
@HiveField(12) @HiveField(12)
final String displayName; final String displayName; // = DisplayName, used for display purposes, can be changed, initially equals InternalName
@HiveField(13) @HiveField(13)
final String? descriptionName; final String? descriptionName; // = DescriptionName, (optional), longer description text, initally nil
@HiveField(14) @HiveField(14)
final String? subscribeKey; final String? subscribeKey;
@HiveField(15) @HiveField(15)

View File

@ -75,7 +75,7 @@ class _ChannelRootPageState extends State<ChannelRootPage> with RouteAware {
() async { () async {
_reloadEnqueued = false; _reloadEnqueued = false;
AppBarState().setLoadingIndeterminate(true); AppBarState().setLoadingIndeterminate(true);
await Future.delayed(const Duration(milliseconds: 500)); // prevents flutter bug where the whole process crashes ?!? await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!?
await _backgroundRefresh(); await _backgroundRefresh();
}(); }();
} }

View File

@ -45,7 +45,7 @@ class _ChannelListItemState extends State<ChannelListItem> {
lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull; lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull;
() async { () async {
final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, channelIDs: [widget.channel.channelID]); final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, filter: MessageFilter(channelIDs: [widget.channel.channelID]));
setState(() { setState(() {
lastMessage = channelMessages.firstOrNull; lastMessage = channelMessages.firstOrNull;
}); });

View File

@ -55,7 +55,7 @@ class _ChannelMessageViewPageState extends State<ChannelMessageViewPage> {
} }
try { try {
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, channelIDs: [this.widget.channel.channelID]); final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: MessageFilter(channelIDs: [this.widget.channel.channelID]));
SCNDataCache().addToMessageCache(newItems); // no await SCNDataCache().addToMessageCache(newItems); // no await

View File

@ -11,8 +11,8 @@ enum MessageFilterChipletType {
} }
class MessageFilterChiplet { class MessageFilterChiplet {
final String label; final String label; // display value
final String value; final dynamic value; // search/api value
final MessageFilterChipletType type; final MessageFilterChipletType type;
MessageFilterChiplet({required this.label, required this.value, required this.type}); MessageFilterChiplet({required this.label, required this.value, required this.type});

View File

@ -39,7 +39,7 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
void initState() { void initState() {
super.initState(); super.initState();
AppEvents().subscribeSearchListener(_onAppBarSearch); AppEvents().subscribeFilterListener(_onAddFilter);
AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification); AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification);
_pagingController.addPageRequestListener(_fetchPage); _pagingController.addPageRequestListener(_fetchPage);
@ -92,7 +92,7 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
@override @override
void dispose() { void dispose() {
ApplicationLog.debug('MessageListPage::dispose'); ApplicationLog.debug('MessageListPage::dispose');
AppEvents().unsubscribeSearchListener(_onAppBarSearch); AppEvents().unsubscribeFilterListener(_onAddFilter);
AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification); AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification);
Navi.modalRouteObserver.unsubscribe(this); Navi.modalRouteObserver.unsubscribe(this);
_pagingController.dispose(); _pagingController.dispose();
@ -139,7 +139,7 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
SCNDataCache().setChannelCache(channels); // no await SCNDataCache().setChannelCache(channels); // no await
} }
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize); final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: _getFilter());
SCNDataCache().addToMessageCache(newItems); // no await SCNDataCache().addToMessageCache(newItems); // no await
@ -267,16 +267,28 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
child: InputChip( child: InputChip(
avatar: Icon(chiplet.icon()), avatar: Icon(chiplet.icon()),
label: Text(chiplet.label), label: Text(chiplet.label),
onDeleted: () => setState(() => _filterChiplets.remove(chiplet)), onDeleted: () => _onRemFilter(chiplet),
onPressed: () {/* TODO idk what to do here ? */}, onPressed: () {/* TODO idk what to do here ? */},
visualDensity: VisualDensity(horizontal: -4, vertical: -4), visualDensity: VisualDensity(horizontal: -4, vertical: -4),
), ),
); );
} }
void _onAppBarSearch(String str) { void _onAddFilter(List<MessageFilterChipletType> remTypeList, List<MessageFilterChiplet> chiplets) {
setState(() { setState(() {
_filterChiplets = _filterChiplets.where((element) => false).toList() + [MessageFilterChiplet(label: str, value: str, type: MessageFilterChipletType.search)]; final remTypes = remTypeList.toSet();
_filterChiplets = _filterChiplets.where((element) => !remTypes.contains(element.type)).toList() + chiplets;
_pagingController.refresh();
});
}
void _onRemFilter(MessageFilterChiplet chiplet) {
setState(() {
_filterChiplets.remove(chiplet);
_pagingController.refresh();
}); });
} }
@ -285,4 +297,35 @@ class _MessageListPageState extends State<MessageListPage> with RouteAware {
_pagingController.itemList = [msg] + (_pagingController.itemList ?? []); _pagingController.itemList = [msg] + (_pagingController.itemList ?? []);
}); });
} }
MessageFilter _getFilter() {
var filter = MessageFilter();
var chipletsChannel = _filterChiplets.where((p) => p.type == MessageFilterChipletType.channel).toList();
if (chipletsChannel.isNotEmpty) {
filter.channelIDs = chipletsChannel.map((p) => p.value as String).toList();
}
var chipletsSearch = _filterChiplets.where((p) => p.type == MessageFilterChipletType.search).toList();
if (chipletsSearch.isNotEmpty) {
filter.searchFilter = chipletsSearch.map((p) => p.value as String).first;
}
var chipletsKeyTokens = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sendkey).toList();
if (chipletsKeyTokens.isNotEmpty) {
filter.usedKeys = chipletsKeyTokens.map((p) => p.value as String).toList();
}
var chipletPriority = _filterChiplets.where((p) => p.type == MessageFilterChipletType.priority).toList();
if (chipletPriority.isNotEmpty) {
filter.priority = chipletPriority.map((p) => p.value as int).toList();
}
var chipletSender = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sender).toList();
if (chipletSender.isNotEmpty) {
filter.senderNames = chipletSender.map((p) => p.value as String).toList();
}
return filter;
}
} }

View File

@ -1,4 +1,5 @@
import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/scn_message.dart';
import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
class AppEvents { class AppEvents {
@ -10,25 +11,30 @@ class AppEvents {
AppEvents._internal() {} AppEvents._internal() {}
List<void Function(String)> _searchListeners = []; // --------------------------------------------------------------------------
List<void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>)> _filterListeners = [];
void subscribeFilterListener(void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>) listener) {
_filterListeners.add(listener);
}
void unsubscribeFilterListener(void Function(List<MessageFilterChipletType> types, List<MessageFilterChiplet>) listener) {
_filterListeners.remove(listener);
}
void notifyFilterListeners(List<MessageFilterChipletType> types, List<MessageFilterChiplet> query) {
ApplicationLog.debug('[AppEvents] onFilter: [${types.join(" ; ")}], [${query.map((e) => e.label).join('|')}]');
for (var listener in _filterListeners) {
listener(types, query);
}
}
// --------------------------------------------------------------------------
List<void Function(SCNMessage)> _messageReceivedListeners = []; List<void Function(SCNMessage)> _messageReceivedListeners = [];
void subscribeSearchListener(void Function(String) listener) {
_searchListeners.add(listener);
}
void unsubscribeSearchListener(void Function(String) listener) {
_searchListeners.remove(listener);
}
void notifySearchListeners(String query) {
ApplicationLog.debug('[AppEvents] onSearch: $query');
for (var listener in _searchListeners) {
listener(query);
}
}
void subscribeMessageReceivedListener(void Function(SCNMessage) listener) { void subscribeMessageReceivedListener(void Function(SCNMessage) listener) {
_messageReceivedListeners.add(listener); _messageReceivedListeners.add(listener);
} }

View File

@ -1,18 +1,26 @@
// This class is useful togther with FutureBuilder // This class is useful togther with FutureBuilder
// Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting // Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting
// Whit way we can set the ImmediateFuture.value directly and circumvent that. // This way we can set the ImmediateFuture.value directly and circumvent that.
class ImmediateFuture<T> { class ImmediateFuture<T> {
final Future<T> future; final Future<T> future;
final T? value; final T? value;
T? _futureValue = null;
ImmediateFuture(this.future, this.value); ImmediateFuture(this.future, this.value);
ImmediateFuture.ofFuture(Future<T> v) ImmediateFuture.ofFuture(Future<T> v)
: future = v, : future = v,
value = null; value = null {
future.then((v) => _futureValue = v);
}
ImmediateFuture.ofValue(T v) ImmediateFuture.ofValue(T v)
: future = Future.value(v), : future = Future.value(v),
value = v; value = v;
T? get() {
return value ?? _futureValue;
}
} }

View File

@ -0,0 +1,87 @@
package handler
import (
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/models"
"gogs.mikescher.com/BlackForestBytes/goext/ginext"
)
// ListUserSenderNames swaggerdoc
//
// @Summary List sender-names (of allthe messages of this user)
// @ID api-usersendernames-list
// @Tags API-v2
//
// @Param uid path string true "UserID"
//
// @Success 200 {object} handler.ListUserKeys.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/users/{uid}/keys [GET]
func (h APIHandler) ListUserSenderNames(pctx ginext.PreContext) ginext.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid" binding:"entityid"`
}
type response struct {
}
var u uri
ctx, g, errResp := pctx.URI(&u).Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil {
return *permResp
}
return nil //TODO
})
}
// ListSenderNames swaggerdoc
//
// @Summary List sender-names (of all messages this user can view, eitehr own or foreign-subscribed)
// @ID api-sendernames-list
// @Tags API-v2
//
// @Success 200 {object} handler.ListSenderNames.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "message not found"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/v2/sender-names [GET]
func (h APIHandler) ListSenderNames(pctx ginext.PreContext) ginext.HTTPResponse {
type response struct {
}
ctx, g, errResp := pctx.Start()
if errResp != nil {
return *errResp
}
defer ctx.Cancel()
return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse {
if permResp := ctx.CheckPermissionAny(); permResp != nil {
return *permResp
}
userid := *ctx.GetPermissionUserID()
if permResp := ctx.CheckPermissionUserRead(userid); permResp != nil {
return *permResp
}
return nil //TODO
})
}

View File

@ -152,10 +152,14 @@ func (r *Router) Init(e *ginext.GinWrapper) error {
apiv2.DELETE("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.CancelSubscription) apiv2.DELETE("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.CancelSubscription)
apiv2.PATCH("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.UpdateSubscription) apiv2.PATCH("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.UpdateSubscription)
apiv2.GET("/users/:uid/sender-names").Handle(r.apiHandler.ListUserSenderNames)
apiv2.GET("/messages").Handle(r.apiHandler.ListMessages) apiv2.GET("/messages").Handle(r.apiHandler.ListMessages)
apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage) apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage)
apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage) apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage)
apiv2.GET("/sender-names").Handle(r.apiHandler.ListSenderNames)
apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview) apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview)
apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview) apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview)
apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview) apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview)

View File

@ -3,9 +3,9 @@ package models
type Channel struct { type Channel struct {
ChannelID ChannelID `db:"channel_id" json:"channel_id"` ChannelID ChannelID `db:"channel_id" json:"channel_id"`
OwnerUserID UserID `db:"owner_user_id" json:"owner_user_id"` OwnerUserID UserID `db:"owner_user_id" json:"owner_user_id"`
InternalName string `db:"internal_name" json:"internal_name"` InternalName string `db:"internal_name" json:"internal_name"` // = InternalName, used for sending, normalized, cannot be changed
DisplayName string `db:"display_name" json:"display_name"` DisplayName string `db:"display_name" json:"display_name"` // = DisplayName, used for display purposes, can be changed, initially equals InternalName
DescriptionName *string `db:"description_name" json:"description_name"` DescriptionName *string `db:"description_name" json:"description_name"` // = DescriptionName, (optional), longer description text, initally nil
SubscribeKey string `db:"subscribe_key" json:"subscribe_key" jsonfilter:"INCLUDE_KEY"` // can be nil, depending on endpoint SubscribeKey string `db:"subscribe_key" json:"subscribe_key" jsonfilter:"INCLUDE_KEY"` // can be nil, depending on endpoint
TimestampCreated SCNTime `db:"timestamp_created" json:"timestamp_created"` TimestampCreated SCNTime `db:"timestamp_created" json:"timestamp_created"`
TimestampLastSent *SCNTime `db:"timestamp_lastsent" json:"timestamp_lastsent"` TimestampLastSent *SCNTime `db:"timestamp_lastsent" json:"timestamp_lastsent"`

View File

@ -698,3 +698,163 @@ func TestListMessagesZeroPagesize(t *testing.T) {
tt.AssertEqual(t, "msgList.PageSize", 1, msgList1.PageSize) tt.AssertEqual(t, "msgList.PageSize", 1, msgList1.PageSize)
tt.AssertEqual(t, "msgList[0]", "Lorem Ipsum 23", msgList1.Messages[0].Title) tt.AssertEqual(t, "msgList[0]", "Lorem Ipsum 23", msgList1.Messages[0].Title)
} }
func TestListMessagesFilterChannel(t *testing.T) {
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
defer stop()
data := tt.InitDefaultData(t, ws)
type msg struct {
ChannelId string `json:"channel_id"`
ChannelInternalName string `json:"channel_internal_name"`
Content string `json:"content"`
MessageId string `json:"message_id"`
OwnerUserId string `json:"owner_user_id"`
Priority int `json:"priority"`
SenderIp string `json:"sender_ip"`
SenderName string `json:"sender_name"`
SenderUserId string `json:"sender_user_id"`
Timestamp string `json:"timestamp"`
Title string `json:"title"`
Trimmed bool `json:"trimmed"`
UsrMessageId string `json:"usr_message_id"`
}
type mglist struct {
Messages []msg `json:"messages"`
}
cid1 := ""
for _, channel := range data.User[0].Channels {
if channel.InternalName == "Reminders" {
cid1 = channel.ChannelID
}
}
cid2 := ""
for _, channel := range data.User[0].Channels {
if channel.InternalName == "Chatting Chamber" {
cid2 = channel.ChannelID
}
}
skey := ""
for _, key := range data.User[0].Keys {
if key.Name == "SendKey (default)" {
skey = key.KeyID
}
}
akey := ""
for _, key := range data.User[0].Keys {
if key.Name == "AdminKey (default)" {
akey = key.KeyID
}
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?channel=%s", "Reminders,Promotions"))
tt.AssertEqual(t, "msgList.len", 9, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?channel=%s", "Reminders"))
tt.AssertEqual(t, "msgList.len", 6, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?channel_id=%s", cid1))
tt.AssertEqual(t, "msgList.len", 6, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?channel_id=%s,%s", cid1, cid2))
tt.AssertEqual(t, "msgList.len", 9, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?filter=%s", "unusual"))
tt.AssertEqual(t, "msgList.len", 1, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?filter=%s", "your"))
tt.AssertEqual(t, "msgList.len", 7, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?priority=%s", "1"))
tt.AssertEqual(t, "msgList.len", 4, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?priority=%s", "2"))
tt.AssertEqual(t, "msgList.len", 6, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?priority=%s", "0"))
tt.AssertEqual(t, "msgList.len", 5, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?priority=%s", "0,2"))
tt.AssertEqual(t, "msgList.len", 11, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?used_key_id=%s", akey))
tt.AssertEqual(t, "msgList.len", 11, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?used_key_id=%s", skey))
tt.AssertEqual(t, "msgList.len", 11, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?used_key_id=%s,%s", akey, skey))
tt.AssertEqual(t, "msgList.len", 22, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?used_key_id=%s&priority=%d", akey, 0))
tt.AssertEqual(t, "msgList.len", 5, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?sender=%s", "Mobile Mate"))
tt.AssertEqual(t, "msgList.len", 3, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?sender=%s", "Pocket Pal"))
tt.AssertEqual(t, "msgList.len", 3, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?sender=%s,%s", "Pocket Pal", "Mobile Mate"))
tt.AssertEqual(t, "msgList.len", 6, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?sender=%s", ""))
tt.AssertEqual(t, "msgList.len", 12, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?before=%s", time.Now().Add(-time.Hour)))
tt.AssertEqual(t, "msgList.len", 2, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?after=%s", time.Now().Add(-time.Hour)))
tt.AssertEqual(t, "msgList.len", 20, len(msgList.Messages))
}
{
msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?after=%s", time.Now().Add(5*time.Minute)))
tt.AssertEqual(t, "msgList.len", 3, len(msgList.Messages))
}
}

View File

@ -0,0 +1,11 @@
package test
import "testing"
func TestListSenderNames(t *testing.T) {
t.Fail()
}
func TestListUserSenderNames(t *testing.T) {
t.Fail()
}

View File

@ -59,15 +59,25 @@ type clientex struct {
FCMTok string FCMTok string
} }
type ChanData struct {
ChannelID string
InternalName string
}
type KeyDat struct {
KeyID string
Name string
}
type Userdat struct { type Userdat struct {
UID string UID string
SendKey string SendKey string
AdminKey string AdminKey string
ReadKey string ReadKey string
Clients []string Clients []string
Channels []string Channels []ChanData
Messages []string Messages []string
Keys []string Keys []KeyDat
Subscriptions []string Subscriptions []string
} }
@ -420,12 +430,13 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
for i, usr := range users { for i, usr := range users {
type schan struct { type schan struct {
ID string `json:"channel_id"` ID string `json:"channel_id"`
InternalName string `json:"internal_name"`
} }
type chanlist struct { type chanlist struct {
Channels []schan `json:"channels"` Channels []schan `json:"channels"`
} }
r0 := RequestAuthGet[chanlist](t, usr.AdminKey, baseUrl, fmt.Sprintf("/api/v2/users/%s/channels?selector=%s", usr.UID, "owned")) r0 := RequestAuthGet[chanlist](t, usr.AdminKey, baseUrl, fmt.Sprintf("/api/v2/users/%s/channels?selector=%s", usr.UID, "owned"))
users[i].Channels = langext.ArrMap(r0.Channels, func(v schan) string { return v.ID }) users[i].Channels = langext.ArrMap(r0.Channels, func(v schan) ChanData { return ChanData{ChannelID: v.ID, InternalName: v.InternalName} })
} }
// list keys // list keys
@ -433,12 +444,13 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
for i, usr := range users { for i, usr := range users {
type skey struct { type skey struct {
ID string `json:"keytoken_id"` ID string `json:"keytoken_id"`
Name string `json:"name"`
} }
type keylist struct { type keylist struct {
Keys []skey `json:"keys"` Keys []skey `json:"keys"`
} }
r0 := RequestAuthGet[keylist](t, usr.AdminKey, baseUrl, fmt.Sprintf("/api/v2/users/%s/keys", usr.UID)) r0 := RequestAuthGet[keylist](t, usr.AdminKey, baseUrl, fmt.Sprintf("/api/v2/users/%s/keys", usr.UID))
users[i].Keys = langext.ArrMap(r0.Keys, func(v skey) string { return v.ID }) users[i].Keys = langext.ArrMap(r0.Keys, func(v skey) KeyDat { return KeyDat{KeyID: v.ID, KeyName: v.Name} })
} }
// list subscriptions // list subscriptions