diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 8e53fbf..6358197 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -5,6 +5,7 @@ import 'package:simplecloudnotifier/api/api_exception.dart'; import 'package:simplecloudnotifier/models/api_error.dart'; import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/keytoken.dart'; +import 'package:simplecloudnotifier/models/sender_name_statistics.dart'; import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; @@ -101,19 +102,21 @@ class APIClient { } if (responseStatusCode != 200) { - try { - final apierr = APIError.fromJson(jsonDecode(responseBody) as Map); + APIError apierr; - RequestLog.addRequestAPIError(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders, apierr); - Toaster.error("Error", 'Request "${name}" failed'); - throw APIException(responseStatusCode, apierr.error, apierr.errhighlight, apierr.message); + try { + apierr = APIError.fromJson(jsonDecode(responseBody) as Map); } catch (exc, trace) { ApplicationLog.warn('Failed to decode api response as error-object', additional: exc.toString() + "\nBody:\n" + responseBody, trace: trace); + + RequestLog.addRequestErrorStatuscode(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders); + Toaster.error("Error", 'Request "${name}" failed'); + throw Exception('API request failed with status code ${responseStatusCode}'); } - RequestLog.addRequestErrorStatuscode(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders); - Toaster.error("Error", 'Request "${name}" failed'); - throw Exception('API request failed with status code ${responseStatusCode}'); + RequestLog.addRequestAPIError(name, t0, method, uri, req.body, req.headers, responseStatusCode, responseBody, responseHeaders, apierr); + Toaster.error("Error", apierr.message); + throw APIException(responseStatusCode, apierr.error, apierr.errhighlight, apierr.message); } try { @@ -281,6 +284,20 @@ class APIClient { ); } + static Future<(String, List)> getChannelMessageList(TokenSource auth, String cid, String pageToken, {int? pageSize}) async { + return await _request( + name: 'getChannelMessageList', + method: 'GET', + relURL: 'users/${auth.getUserID()}/channels/${cid}/messages', + query: { + 'next_page_token': [pageToken], + if (pageSize != null) 'page_size': [pageSize.toString()], + }, + fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'), + authToken: auth.getToken(), + ); + } + static Future> getSubscriptionList(TokenSource auth) async { return await _request( name: 'getSubscriptionList', @@ -369,7 +386,62 @@ class APIClient { ); } - static Future> getSenderNameList(AppAuth userAcc) { - return Future.value(['TODO']); //TODO + static Future> getSenderNameList(TokenSource auth) async { + return await _request( + name: 'getSenderNameList', + method: 'GET', + relURL: 'users/${auth.getUserID()}/sender-names', + fn: (json) => SenderNameStatistics.fromJsonArray(json['sender_names'] as List), + authToken: auth.getToken(), + ); + } + + static Future subscribeToChannelbyID(TokenSource auth, String channelID) async { + return await _request( + name: 'subscribeToChannelbyID', + method: 'POST', + relURL: 'users/${auth.getUserID()}/subscriptions', + jsonBody: { + 'channel_id': channelID, + }, + fn: Subscription.fromJson, + authToken: auth.getToken(), + ); + } + + static Future deleteSubscription(TokenSource auth, String channelID, String subID) async { + return await _request( + name: 'deleteSubscription', + method: 'DELETE', + relURL: 'users/${auth.getUserID()}/subscriptions/${subID}', + fn: Subscription.fromJson, + authToken: auth.getToken(), + ); + } + + static Future confirmSubscription(TokenSource auth, String channelID, String subID) async { + return await _request( + name: 'confirmSubscription', + method: 'PATCH', + relURL: 'users/${auth.getUserID()}/subscriptions/${subID}', + jsonBody: { + 'confirmed': true, + }, + fn: Subscription.fromJson, + authToken: auth.getToken(), + ); + } + + static Future unconfirmSubscription(TokenSource auth, String channelID, String subID) async { + return await _request( + name: 'unconfirmSubscription', + method: 'PATCH', + relURL: 'users/${auth.getUserID()}/subscriptions/${subID}', + jsonBody: { + 'confirmed': false, + }, + fn: Subscription.fromJson, + authToken: auth.getToken(), + ); } } diff --git a/flutter/lib/api/api_exception.dart b/flutter/lib/api/api_exception.dart index d76125f..a6a72b1 100644 --- a/flutter/lib/api/api_exception.dart +++ b/flutter/lib/api/api_exception.dart @@ -1,7 +1,7 @@ class APIException implements Exception { final int httpStatus; final int error; - final String errHighlight; + final int errHighlight; final String message; APIException(this.httpStatus, this.error, this.errHighlight, this.message); diff --git a/flutter/lib/components/modals/filter_modal_sendername.dart b/flutter/lib/components/modals/filter_modal_sendername.dart index 2bc6c76..3cdfd7a 100644 --- a/flutter/lib/components/modals/filter_modal_sendername.dart +++ b/flutter/lib/components/modals/filter_modal_sendername.dart @@ -25,7 +25,7 @@ class _FilterModalSendernameState extends State { final userAcc = Provider.of(context, listen: false); if (!userAcc.isAuth()) throw new Exception('not logged in'); - final senders = await APIClient.getSenderNameList(userAcc); + final senders = (await APIClient.getSenderNameList(userAcc)).map((p) => p.name).toList(); return senders; }()); diff --git a/flutter/lib/models/api_error.dart b/flutter/lib/models/api_error.dart index 14f8ea0..7448daa 100644 --- a/flutter/lib/models/api_error.dart +++ b/flutter/lib/models/api_error.dart @@ -1,7 +1,7 @@ class APIError { final bool success; final int error; - final String errhighlight; + final int errhighlight; final String message; static final MISSING_UID = 1101; @@ -67,7 +67,7 @@ class APIError { return APIError( success: json['success'] as bool, error: (json['error'] as num).toInt(), - errhighlight: json['errhighlight'] as String, + errhighlight: (json['errhighlight'] as num).toInt(), message: json['message'] as String, ); } diff --git a/flutter/lib/models/sender_name_statistics.dart b/flutter/lib/models/sender_name_statistics.dart new file mode 100644 index 0000000..744299d --- /dev/null +++ b/flutter/lib/models/sender_name_statistics.dart @@ -0,0 +1,26 @@ +class SenderNameStatistics { + final String name; + final String ts_last; + final String ts_first; + final int count; + + const SenderNameStatistics({ + required this.name, + required this.ts_last, + required this.ts_first, + required this.count, + }); + + factory SenderNameStatistics.fromJson(Map json) { + return SenderNameStatistics( + name: json['name'] as String, + ts_last: json['ts_last'] as String, + ts_first: json['ts_first'] as String, + count: json['count'] as int, + ); + } + + static List fromJsonArray(List jsonArr) { + return jsonArr.map((e) => SenderNameStatistics.fromJson(e as Map)).toList(); + } +} diff --git a/flutter/lib/pages/account/account.dart b/flutter/lib/pages/account/account.dart index 6074f6c..1df9412 100644 --- a/flutter/lib/pages/account/account.dart +++ b/flutter/lib/pages/account/account.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/pages/account/login.dart'; +import 'package:simplecloudnotifier/pages/channel_list/channel_list_extended.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/globals.dart'; @@ -32,6 +33,7 @@ class _AccountRootPageState extends State { late ImmediateFuture? futureKeyCount; late ImmediateFuture? futureChannelAllCount; late ImmediateFuture? futureChannelSubscribedCount; + late ImmediateFuture? futureSenderNamesCount; late ImmediateFuture? futureUser; late AppAuth userAcc; @@ -87,6 +89,7 @@ class _AccountRootPageState extends State { futureKeyCount = null; futureChannelAllCount = null; futureChannelSubscribedCount = null; + futureSenderNamesCount = null; if (userAcc.isAuth()) { futureChannelAllCount = ImmediateFuture.ofFuture(() async { @@ -119,6 +122,12 @@ class _AccountRootPageState extends State { return keys.length; }()); + futureSenderNamesCount = ImmediateFuture.ofFuture(() async { + if (!userAcc.isAuth()) throw new Exception('not logged in'); + final senders = (await APIClient.getSenderNameList(userAcc)).map((p) => p.name).toList(); + return senders.length; + }()); + futureUser = ImmediateFuture.ofFuture(userAcc.loadUser(force: false)); } } @@ -137,6 +146,7 @@ class _AccountRootPageState extends State { final subs = await APIClient.getSubscriptionList(userAcc); final clients = await APIClient.getClientList(userAcc); final keys = await APIClient.getKeyTokenList(userAcc); + final senderNames = await APIClient.getSenderNameList(userAcc); final user = await userAcc.loadUser(force: true); setState(() { @@ -145,6 +155,7 @@ class _AccountRootPageState extends State { futureSubscriptionCount = ImmediateFuture.ofValue(subs.length); futureClientCount = ImmediateFuture.ofValue(clients.length); futureKeyCount = ImmediateFuture.ofValue(keys.length); + futureSenderNamesCount = ImmediateFuture.ofValue(senderNames.length); futureUser = ImmediateFuture.ofValue(user); }); } catch (exc, trace) { @@ -368,7 +379,10 @@ class _AccountRootPageState extends State { _buildNumberCard(context, 'Subscriptions', futureSubscriptionCount, () {/*TODO*/}), _buildNumberCard(context, 'Clients', futureClientCount, () {/*TODO*/}), _buildNumberCard(context, 'Keys', futureKeyCount, () {/*TODO*/}), - _buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () {/*TODO*/}), + _buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () { + Navi.push(context, () => ChannelListExtendedPage()); + }), + _buildNumberCard(context, 'Sender', futureSenderNamesCount, () {/*TODO*/}), UI.buttonCard( context: context, margin: EdgeInsets.fromLTRB(0, 4, 0, 4), diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index cdae5d5..93d075e 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -4,7 +4,6 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/channel.dart'; -import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; @@ -154,8 +153,13 @@ class _ChannelRootPageState extends State with RouteAware { itemBuilder: (context, item, index) => ChannelListItem( channel: item.channel, subscription: item.subscription, - onPressed: () { - Navi.push(context, () => ChannelViewPage(channelID: item.channel.channelID, preloadedData: (item.channel, item.subscription), needsReload: _enqueueReload)); + mode: ChannelListItemMode.Messages, + onChannelListReloadTrigger: _enqueueReload, + onSubscriptionChanged: (channelID, subscription) { + setState(() { + final idx = _pagingController.itemList?.indexWhere((p) => p.channel.channelID == channelID); + if (idx != null && idx >= 0) _pagingController.itemList![idx] = ChannelWithSubscription(channel: _pagingController.itemList![idx].channel, subscription: subscription); + }); }, ), ), diff --git a/flutter/lib/pages/channel_list/channel_list_extended.dart b/flutter/lib/pages/channel_list/channel_list_extended.dart new file mode 100644 index 0000000..62fec47 --- /dev/null +++ b/flutter/lib/pages/channel_list/channel_list_extended.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/components/layout/scaffold.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; +import 'package:simplecloudnotifier/state/application_log.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; + +class ChannelListExtendedPage extends StatefulWidget { + const ChannelListExtendedPage({super.key}); + + @override + State createState() => _ChannelListExtendedPageState(); +} + +class _ChannelListExtendedPageState extends State with RouteAware { + final PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); + + bool _reloadEnqueued = false; + + @override + void initState() { + super.initState(); + + _pagingController.addPageRequestListener(_fetchPage); + + _pagingController.refresh(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!); + } + + @override + void dispose() { + ApplicationLog.debug('ChannelRootPage::dispose'); + _pagingController.dispose(); + Navi.modalRouteObserver.unsubscribe(this); + super.dispose(); + } + + @override + void didPopNext() { + if (_reloadEnqueued) { + ApplicationLog.debug('[ChannelList::RouteObserver] --> didPopNext (will background-refresh) (_reloadEnqueued == true)'); + () async { + _reloadEnqueued = false; + await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!? + await _backgroundRefresh(); + }(); + } + } + + Future _fetchPage(int pageKey) async { + final acc = Provider.of(context, listen: false); + + ApplicationLog.debug('Start ChannelList::_pagingController::_fetchPage [ ${pageKey} ]'); + + if (!acc.isAuth()) { + _pagingController.error = 'Not logged in'; + return; + } + + try { + final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList(); + + items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? '')); + + _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); + } catch (exc, trace) { + _pagingController.error = exc.toString(); + ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace); + } + } + + Future _backgroundRefresh() async { + final acc = Provider.of(context, listen: false); + + ApplicationLog.debug('Start background refresh of channel list'); + + if (!acc.isAuth()) { + _pagingController.error = 'Not logged in'; + return; + } + + try { + await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... + + AppBarState().setLoadingIndeterminate(true); + + final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList(); + + items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? '')); + + setState(() { + _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); + }); + } catch (exc, trace) { + setState(() { + _pagingController.error = exc.toString(); + }); + ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace); + } finally { + AppBarState().setLoadingIndeterminate(false); + } + } + + @override + Widget build(BuildContext context) { + return SCNScaffold( + title: "Channels", + showSearch: false, + showShare: false, + child: Padding( + padding: EdgeInsets.fromLTRB(8, 4, 8, 4), + child: RefreshIndicator( + onRefresh: () => Future.sync( + () => _pagingController.refresh(), + ), + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ChannelListItem( + channel: item.channel, + subscription: item.subscription, + mode: ChannelListItemMode.Extended, + onChannelListReloadTrigger: _enqueueReload, + onSubscriptionChanged: (channelID, subscription) { + setState(() { + final idx = _pagingController.itemList?.indexWhere((p) => p.channel.channelID == channelID); + if (idx != null && idx >= 0) _pagingController.itemList![idx] = ChannelWithSubscription(channel: _pagingController.itemList![idx].channel, subscription: subscription); + }); + }, + ), + ), + ), + ), + ), + ); + } + + void _enqueueReload() { + _reloadEnqueued = true; + } +} diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index 806ae27..731fc11 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -7,23 +7,35 @@ import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart'; +import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/scn_data_cache.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; +import 'package:simplecloudnotifier/utils/toaster.dart'; + +enum ChannelListItemMode { + Messages, + Extended, +} class ChannelListItem extends StatefulWidget { static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); const ChannelListItem({ required this.channel, - required this.onPressed, + required this.onChannelListReloadTrigger, + required this.onSubscriptionChanged, required this.subscription, + required this.mode, super.key, }); final Channel channel; final Subscription? subscription; - final Null Function() onPressed; + final void Function() onChannelListReloadTrigger; + final ChannelListItemMode mode; + final void Function(String, Subscription?) onSubscriptionChanged; @override State createState() => _ChannelListItemState(); @@ -38,11 +50,11 @@ class _ChannelListItemState extends State { final acc = Provider.of(context, listen: false); - if (acc.isAuth()) { + if (acc.isAuth() && widget.mode == ChannelListItemMode.Messages) { lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull; () async { - final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, filter: MessageFilter(channelIDs: [widget.channel.channelID])); + final (_, channelMessages) = await APIClient.getChannelMessageList(acc, widget.channel.channelID, '@start', pageSize: 1); setState(() { lastMessage = channelMessages.firstOrNull; }); @@ -52,13 +64,18 @@ class _ChannelListItemState extends State { @override Widget build(BuildContext context) { - //TODO subscription status return Card.filled( margin: EdgeInsets.fromLTRB(0, 4, 0, 4), shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), color: Theme.of(context).cardTheme.color, child: InkWell( - onTap: widget.onPressed, + onTap: () { + if (widget.mode == ChannelListItemMode.Messages) { + Navi.push(context, () => ChannelMessageViewPage(channel: widget.channel)); + } else { + Navi.push(context, () => ChannelViewPage(channelID: widget.channel.channelID, preloadedData: (widget.channel, widget.subscription), needsReload: widget.onChannelListReloadTrigger)); + } + }, child: Padding( padding: const EdgeInsets.all(8), child: Row( @@ -87,13 +104,8 @@ class _ChannelListItemState extends State { Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded( - child: Text( - _preformatTitle(lastMessage), - style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), - ), - ), - Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + Expanded(child: (widget.mode == ChannelListItemMode.Messages) ? Text(_preformatTitle(lastMessage), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))) : _buildSubscriptionStateText(context)), + (widget.mode == ChannelListItemMode.Messages) ? Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)) : Text("", style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), ], ), ], @@ -102,11 +114,15 @@ class _ChannelListItemState extends State { SizedBox(width: 4), GestureDetector( onTap: () { - Navi.push(context, () => ChannelMessageViewPage(channel: this.widget.channel)); + if (widget.mode == ChannelListItemMode.Messages) { + Navi.push(context, () => ChannelViewPage(channelID: widget.channel.channelID, preloadedData: (widget.channel, widget.subscription), needsReload: widget.onChannelListReloadTrigger)); + } else { + Navi.push(context, () => ChannelMessageViewPage(channel: widget.channel)); + } }, child: Padding( padding: const EdgeInsets.all(8), - child: Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24), + child: (widget.mode == ChannelListItemMode.Messages) ? Icon(FontAwesomeIcons.solidSquareInfo, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24) : Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24), ), ), ], @@ -123,13 +139,73 @@ class _ChannelListItemState extends State { Widget _buildIcon(BuildContext context) { if (widget.subscription == null) { - return Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed + Widget result = Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed + result = GestureDetector(onTap: () => _subscribe(), child: result); + return result; } else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { - return Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel) + Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel) + result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result); + return result; } else if (widget.subscription!.confirmed) { - return Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel) + Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel) + result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result); + return result; } else { - return Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested + Widget result = Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested + result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result); + return result; + } + } + + Widget _buildSubscriptionStateText(BuildContext context) { + if (widget.subscription == null) { + return Text("", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); + } else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { + return Text("subscribed", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); + } else if (widget.subscription!.confirmed) { + return Text("subscripted (foreign channe)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); + } else { + return Text("subscription requested", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); + } + } + + void _subscribe() async { + final acc = AppAuth(); + + if (acc.isAuth() && widget.channel.ownerUserID == acc.getUserID()) { + try { + var sub = await APIClient.subscribeToChannelbyID(acc, widget.channel.channelID); + widget.onChannelListReloadTrigger.call(); + + widget.onSubscriptionChanged(widget.channel.channelID, sub); + + if (sub.confirmed) { + Toaster.success("Success", 'Subscribed to channel'); + } else { + Toaster.success("Success", 'Requested widget.subscription to channel'); + } + } catch (exc, trace) { + Toaster.error("Error", 'Failed to subscribe to channel'); + ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace); + } + } + } + + void _unsubscribe(Subscription sub) async { + final acc = AppAuth(); + + if (acc.isAuth() && widget.channel.ownerUserID == acc.getUserID() && widget.subscription != null) { + try { + await APIClient.deleteSubscription(acc, widget.channel.channelID, widget.subscription!.subscriptionID); + widget.onChannelListReloadTrigger.call(); + + widget.onSubscriptionChanged?.call(widget.channel.channelID, null); + + Toaster.success("Success", 'Unsubscribed from channel'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to unsubscribe from channel'); + ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace); + } } } } diff --git a/flutter/lib/pages/channel_message_view/channel_message_view.dart b/flutter/lib/pages/channel_message_view/channel_message_view.dart index 704b9eb..de7a086 100644 --- a/flutter/lib/pages/channel_message_view/channel_message_view.dart +++ b/flutter/lib/pages/channel_message_view/channel_message_view.dart @@ -55,7 +55,7 @@ class _ChannelMessageViewPageState extends State { } try { - final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: MessageFilter(channelIDs: [this.widget.channel.channelID])); + final (npt, newItems) = await APIClient.getChannelMessageList(acc, this.widget.channel.channelID, thisPageToken, pageSize: cfg.messagePageSize); SCNDataCache().addToMessageCache(newItems); // no await diff --git a/flutter/lib/pages/channel_view/channel_view.dart b/flutter/lib/pages/channel_view/channel_view.dart index e812376..cd397b4 100644 --- a/flutter/lib/pages/channel_view/channel_view.dart +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -63,15 +63,15 @@ class _ChannelViewPageState extends State { @override void initState() { - _initStateAsync(); + _initStateAsync(true); super.initState(); } - void _initStateAsync() async { + Future _initStateAsync(bool usePreload) async { final userAcc = Provider.of(context, listen: false); - if (widget.preloadedData != null) { + if (widget.preloadedData != null && usePreload) { channelPreview = widget.preloadedData!.$1.toPreview(); channel = widget.preloadedData!.$1; subscription = widget.preloadedData!.$2; @@ -231,7 +231,7 @@ class _ChannelViewPageState extends State { UI.metaCard( context: context, icon: FontAwesomeIcons.solidDiagramSubtask, - title: 'Subscription (own)', + title: 'Subscription (foreign)', values: [_formatSubscriptionStatus(subscription)], iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], ), @@ -296,7 +296,7 @@ class _ChannelViewPageState extends State { future: _futureSubscribeKey.future, builder: (context, snapshot) { if (snapshot.hasData && snapshot.data != null) { - var text = 'TODO' + '\n' + channel!.channelID + '\n' + snapshot.data!; //TODO deeplink-y (also perhaps just bas64 everything together?) + var text = '@scn.channel.subscribe' + '\n' + "v1" + '\n' + channel!.displayName + '\n' + channel!.ownerUserID + '\n' + channel!.channelID + '\n' + snapshot.data!; return GestureDetector( onTap: () { Share.share(text, subject: _displayNameOverride ?? channel!.displayName); @@ -305,7 +305,7 @@ class _ChannelViewPageState extends State { child: QrImageView( data: text, version: QrVersions.auto, - size: 300.0, + size: 265.0, eyeStyle: QrEyeStyle( eyeShape: QrEyeShape.square, color: Theme.of(context).textTheme.bodyLarge?.color, @@ -446,14 +446,6 @@ class _ChannelViewPageState extends State { } } - void _subscribe() { - //TODO - } - - void _unsubscribe() { - //TODO - } - void _showEditDisplayName() { setState(() { _ctrlDisplayName.text = _displayNameOverride ?? channelPreview?.displayName ?? ''; @@ -518,16 +510,90 @@ class _ChannelViewPageState extends State { } } - void _cancelForeignSubscription(Subscription sub) { - //TODO + void _subscribe() async { + final acc = AppAuth(); + + try { + var sub = await APIClient.subscribeToChannelbyID(acc, widget.channelID); + widget.needsReload?.call(); + + await _initStateAsync(false); + + if (sub.confirmed) { + Toaster.success("Success", 'Subscribed to channel'); + } else { + Toaster.success("Success", 'Requested subscription to channel'); + } + } catch (exc, trace) { + Toaster.error("Error", 'Failed to subscribe to channel'); + ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace); + } } - void _confirmForeignSubscription(Subscription sub) { - //TODO + void _unsubscribe() async { + final acc = AppAuth(); + + if (subscription == null) return; + + try { + await APIClient.deleteSubscription(acc, widget.channelID, subscription!.subscriptionID); + widget.needsReload?.call(); + + await _initStateAsync(false); + + Toaster.success("Success", 'Unsubscribed from channel'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to unsubscribe from channel'); + ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace); + } } - void _denyForeignSubscription(Subscription sub) { - //TODO + void _cancelForeignSubscription(Subscription sub) async { + final acc = AppAuth(); + + try { + await APIClient.unconfirmSubscription(acc, widget.channelID, subscription!.subscriptionID); + widget.needsReload?.call(); + + await _initStateAsync(false); + + Toaster.success("Success", 'Subscription succesfully revoked'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to revoke subscription'); + ApplicationLog.error('Failed to revoke subscription: ' + exc.toString(), trace: trace); + } + } + + void _confirmForeignSubscription(Subscription sub) async { + final acc = AppAuth(); + + try { + await APIClient.confirmSubscription(acc, widget.channelID, subscription!.subscriptionID); + widget.needsReload?.call(); + + await _initStateAsync(false); + + Toaster.success("Success", 'Subscription succesfully confirmed'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to confirm subscription'); + ApplicationLog.error('Failed to confirm subscription: ' + exc.toString(), trace: trace); + } + } + + void _denyForeignSubscription(Subscription sub) async { + final acc = AppAuth(); + + try { + await APIClient.deleteSubscription(acc, widget.channelID, subscription!.subscriptionID); + widget.needsReload?.call(); + + await _initStateAsync(false); + + Toaster.success("Success", 'Subscription request succesfully denied'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to deny subscription'); + ApplicationLog.error('Failed to deny subscription: ' + exc.toString(), trace: trace); + } } String _formatSubscriptionStatus(Subscription? subscription) { diff --git a/flutter/lib/utils/ui.dart b/flutter/lib/utils/ui.dart index 4a81ad7..d00cb4f 100644 --- a/flutter/lib/utils/ui.dart +++ b/flutter/lib/utils/ui.dart @@ -72,7 +72,7 @@ class UI { splashColor: Theme.of(context).splashColor, onTap: onTap, child: Padding( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: child, ), ), diff --git a/scnserver/api/handler/apiChannel.go b/scnserver/api/handler/apiChannel.go index ca43144..c0d6ed5 100644 --- a/scnserver/api/handler/apiChannel.go +++ b/scnserver/api/handler/apiChannel.go @@ -411,7 +411,6 @@ func (h APIHandler) ListChannelMessages(pctx ginext.PreContext) ginext.HTTPRespo type query struct { PageSize *int `json:"page_size" form:"page_size"` NextPageToken *string `json:"next_page_token" form:"next_page_token"` - Filter *string `json:"filter" form:"filter"` Trimmed *bool `json:"trimmed" form:"trimmed"` } type response struct {