diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index d8e8bc9..b7e18ab 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -828,6 +828,28 @@ class MessagesModule { return ''; } + Future sendLinkMessage(int chatId, String url) async { + final message = { + 'text': url, + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'elements': [ + { + 'type': 'LINK', + 'from': 0, + 'length': url.length, + 'attributes': {'url': url}, + }, + ], + 'attaches': [], + }; + final response = await _api.sendRequest(Opcode.msgSend, { + 'chatId': chatId, + 'message': message, + 'notify': true, + }); + return response.isOk; + } + /// Загружает отложенные (запланированные) сообщения чата. /// /// В отличие от обычной истории, отложенные сообщения не сохраняются diff --git a/lib/backend/modules/stickers.dart b/lib/backend/modules/stickers.dart index 2e07a2e..673cda5 100644 --- a/lib/backend/modules/stickers.dart +++ b/lib/backend/modules/stickers.dart @@ -1,12 +1,17 @@ import '../api.dart'; import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; import '../../core/utils/logger.dart'; import '../../models/sticker.dart'; class StickersModule { final Api _api; - StickersModule(this._api); + StickersModule(this._api) { + _api.pushStream + .where((p) => p.opcode == Opcode.notifAssetsUpdate) + .listen(_handleAssetsPush); + } final Map _sets = {}; final Map _stickers = {}; @@ -15,6 +20,7 @@ class StickersModule { List _recentStickerIds = []; Future? _loading; + Future? _favoritesLoading; List get sets => _orderedSetIds.map((id) => _sets[id]).whereType().toList(); @@ -22,6 +28,8 @@ class StickersModule { List get favoriteSetIds => _favoriteSetIds; List get recentStickerIds => _recentStickerIds; StickerItem? cachedSticker(int id) => _stickers[id]; + StickerSet? cachedSet(int id) => _sets[id]; + bool isFavorite(int setId) => _favoriteSetIds.contains(setId); Future ensureLoaded() { return _loading ??= _loadSections().catchError((Object e) { @@ -30,6 +38,32 @@ class StickersModule { }); } + Future ensureFavoritesLoaded() { + return _favoritesLoading ??= _loadFavorites().catchError((Object e) { + _favoritesLoading = null; + throw e; + }); + } + + Future _loadFavorites() async { + final favIds = []; + final favResp = await _api.sendRequest(Opcode.assetsUpdate, { + 'type': 'FAVORITE_STICKER', + 'sync': 0, + }); + if (favResp.isOk && favResp.payload is Map) { + final sections = favResp.payload['sections']; + if (sections is List) { + for (final s in sections) { + if (s is Map && s['id'] == 'FAVORITE_STICKER_SETS') { + _appendIntList(favIds, s['stickerSets']); + } + } + } + } + _favoriteSetIds = favIds; + } + Future _loadSections() async { final newSetIds = []; int marker = 0; @@ -70,26 +104,11 @@ class StickersModule { marker = m is int ? m : 0; } - final favIds = []; - final favResp = await _api.sendRequest(Opcode.assetsUpdate, { - 'type': 'FAVORITE_STICKER', - 'sync': 0, - }); - if (favResp.isOk && favResp.payload is Map) { - final sections = favResp.payload['sections']; - if (sections is List) { - for (final s in sections) { - if (s is Map && s['id'] == 'FAVORITE_STICKER_SETS') { - _appendIntList(favIds, s['stickerSets']); - } - } - } - } - _favoriteSetIds = favIds; + await ensureFavoritesLoaded(); final ordered = []; final seen = {}; - for (final id in [...favIds, ...newSetIds]) { + for (final id in [..._favoriteSetIds, ...newSetIds]) { if (seen.add(id)) ordered.add(id); } _orderedSetIds = ordered; @@ -140,6 +159,74 @@ class StickersModule { .toList(); } + Future ensureSet(int setId) async { + await _ensureSetMetas([setId]); + return _sets[setId]; + } + + void cacheSet(StickerSet set) => _sets[set.id] = set; + + Future resolveSetByLink(String link) async { + final resp = await _api.sendRequest(Opcode.linkInfo, {'link': link}); + if (!resp.isOk || resp.payload is! Map) return null; + final raw = resp.payload['stickerSet']; + if (raw is! Map || raw['id'] is! int) return null; + final set = StickerSet.fromMap(raw); + _sets[set.id] = set; + return set; + } + + Future resolveSetId(int stickerId) async { + await ensureStickers([stickerId]); + return _stickers[stickerId]?.setId; + } + + Future favoriteSet(int setId) async { + final resp = await _api.sendRequest(Opcode.assetsAdd, { + 'type': 'FAVORITE_STICKER_SET', + 'id': setId, + }); + final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true; + if (ok) _markFavorite(setId, true); + return ok; + } + + Future unfavoriteSet(int setId) async { + final resp = await _api.sendRequest(Opcode.assetsRemove, { + 'type': 'FAVORITE_STICKER_SET', + 'ids': [setId], + }); + final ok = resp.isOk && resp.payload is Map && resp.payload['success'] == true; + if (ok) _markFavorite(setId, false); + return ok; + } + + void _handleAssetsPush(Packet push) { + final payload = push.payload; + if (payload is! Map) return; + if (payload['type'] != 'FAVORITE_STICKER_SET') return; + final id = payload['id']; + if (id is! int) return; + switch (payload['updateType']) { + case 'ADDED': + _markFavorite(id, true); + case 'REMOVED': + _markFavorite(id, false); + } + } + + void _markFavorite(int setId, bool favorite) { + if (favorite) { + if (!_favoriteSetIds.contains(setId)) { + _favoriteSetIds = [setId, ..._favoriteSetIds]; + } + } else { + if (_favoriteSetIds.contains(setId)) { + _favoriteSetIds = _favoriteSetIds.where((id) => id != setId).toList(); + } + } + } + void _parseRecents(dynamic list) { if (list is! List) return; final ids = []; diff --git a/lib/core/links/max_link.dart b/lib/core/links/max_link.dart index 5f0a679..ff460c6 100644 --- a/lib/core/links/max_link.dart +++ b/lib/core/links/max_link.dart @@ -1,4 +1,4 @@ -enum MaxLinkKind { call, invite, user, content, public, auth } +enum MaxLinkKind { call, invite, user, content, public, auth, stickerSet } class MaxLink { final MaxLinkKind kind; @@ -49,6 +49,10 @@ class MaxLink { return segments.length >= 2 ? MaxLink(MaxLinkKind.user, url) : null; case 'c': return segments.length >= 3 ? MaxLink(MaxLinkKind.content, url) : null; + case 'stickerset': + return segments.length >= 2 + ? MaxLink(MaxLinkKind.stickerSet, url) + : null; } if (_reserved.contains(segments.first.toLowerCase())) return null; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 5a6048c..18c55cd 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -62,6 +62,7 @@ import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/sticker_panel.dart'; +import '../../widgets/sticker_pack_sheet.dart'; import '../../widgets/swipe_to_pop.dart'; import '../../widgets/schedule_time_picker.dart'; import 'scheduled_messages_screen.dart'; @@ -3105,6 +3106,19 @@ class _ChatScreenState extends State ); } + void _openStickerPack(StickerAttachment sticker) { + final stickerId = int.tryParse(sticker.stickerId ?? ''); + if (stickerId == null) { + showCustomNotification(context, 'Стикерпак недоступен'); + return; + } + showStickerPackSheet( + context, + stickerId: stickerId, + knownSetId: int.tryParse(sticker.stickerPackId ?? ''), + ); + } + void _jumpToMessage(String messageId) { final index = _messages.indexWhere((m) => m.id == messageId); if (index == -1) { @@ -3452,6 +3466,7 @@ class _ChatScreenState extends State uploadProgress: _photoProgressFor(message), onReplyTap: _jumpToMessage, onAvatarTap: _openSenderProfile, + onStickerTap: _openStickerPack, ); final canReport = !isMe && !message.isControl; diff --git a/lib/frontend/widgets/max_link_handler.dart b/lib/frontend/widgets/max_link_handler.dart index 227861b..2392529 100644 --- a/lib/frontend/widgets/max_link_handler.dart +++ b/lib/frontend/widgets/max_link_handler.dart @@ -10,6 +10,7 @@ import '../screens/contacts/contact_profile_screen.dart'; import 'call_link_handler.dart'; import 'confirm_dialog.dart'; import 'custom_notification.dart'; +import 'sticker_pack_sheet.dart'; import 'swipe_route.dart'; import 'web_qr_login.dart'; @@ -26,6 +27,10 @@ Future tryHandleMaxLink(BuildContext context, String url) async { return true; } + if (link.kind == MaxLinkKind.stickerSet) { + return _openStickerSet(context, link.url); + } + final resolved = await LinkModule.resolve(api, link.url); if (!context.mounted) return true; @@ -44,6 +49,26 @@ Future tryHandleMaxLink(BuildContext context, String url) async { } } +Future _openStickerSet(BuildContext context, String url) async { + final path = url + .replaceFirst( + RegExp(r'^https?://(?:www\.)?max\.ru/', caseSensitive: false), + '', + ) + .split('?') + .first + .split('#') + .first; + final set = await stickersModule.resolveSetByLink(path); + if (!context.mounted) return true; + if (set == null) { + showCustomNotification(context, 'Стикерпак недоступен'); + return true; + } + await showStickerPackSheet(context, knownSetId: set.id); + return true; +} + void _openContact(BuildContext context, Map contact) { final id = contact['id']; if (id is! int) { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 015314f..d732173 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -111,6 +111,7 @@ class MessageBubble extends StatelessWidget { final ValueListenable>? uploadProgress; final void Function(String messageId)? onReplyTap; final void Function(int senderId)? onAvatarTap; + final void Function(StickerAttachment sticker)? onStickerTap; const MessageBubble({ super.key, @@ -125,6 +126,7 @@ class MessageBubble extends StatelessWidget { this.uploadProgress, this.onReplyTap, this.onAvatarTap, + this.onStickerTap, }); bool _computeHasPhotoWithCaption() { @@ -2335,7 +2337,7 @@ class MessageBubble extends StatelessWidget { final preview = sticker.previewData ?? ''; final imageUrl = url.isNotEmpty ? url : preview; - return ClipRRect( + final Widget image = ClipRRect( borderRadius: BorderRadius.circular(photoBorderRadius), child: Stack( children: [ @@ -2356,6 +2358,14 @@ class MessageBubble extends StatelessWidget { ], ), ); + + final onTap = onStickerTap; + if (onTap == null || sticker is! StickerAttachment) return image; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(sticker), + child: image, + ); } Widget _buildContactAttachment(_BubbleCtx ctx, MessageAttachment contact) { diff --git a/lib/frontend/widgets/sticker_pack_sheet.dart b/lib/frontend/widgets/sticker_pack_sheet.dart new file mode 100644 index 0000000..e03ff28 --- /dev/null +++ b/lib/frontend/widgets/sticker_pack_sheet.dart @@ -0,0 +1,351 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../core/storage/app_database.dart'; +import '../../main.dart' show stickersModule, messagesModule; +import '../../models/sticker.dart'; +import '../screens/chats/forward_picker_screen.dart'; +import 'custom_notification.dart'; + +enum _PackAction { forward, copyLink } + +Future showStickerPackSheet( + BuildContext context, { + int? stickerId, + int? knownSetId, +}) { + assert(stickerId != null || knownSetId != null); + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _StickerPackSheet( + stickerId: stickerId, + knownSetId: knownSetId, + ), + ); +} + +class _StickerPackSheet extends StatefulWidget { + final int? stickerId; + final int? knownSetId; + + const _StickerPackSheet({this.stickerId, this.knownSetId}); + + @override + State<_StickerPackSheet> createState() => _StickerPackSheetState(); +} + +class _StickerPackSheetState extends State<_StickerPackSheet> { + bool _loading = true; + bool _busy = false; + bool _isFavorite = false; + Object? _error; + StickerSet? _set; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final setId = widget.knownSetId ?? + (widget.stickerId != null + ? await stickersModule.resolveSetId(widget.stickerId!) + : null); + if (setId == null) throw Exception('no set'); + final setFuture = stickersModule.ensureSet(setId); + final favoritesFuture = stickersModule.ensureFavoritesLoaded(); + final set = await setFuture; + await favoritesFuture; + if (set == null) throw Exception('no meta'); + await stickersModule.ensureStickers(set.stickerIds); + if (!mounted) return; + setState(() { + _set = set; + _isFavorite = stickersModule.isFavorite(set.id); + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e; + _loading = false; + }); + } + } + + Future _toggle() async { + final set = _set; + if (set == null || _busy) return; + setState(() => _busy = true); + final wasFavorite = _isFavorite; + try { + final ok = wasFavorite + ? await stickersModule.unfavoriteSet(set.id) + : await stickersModule.favoriteSet(set.id); + if (!mounted) return; + if (ok) { + setState(() => _isFavorite = !wasFavorite); + showCustomNotification( + context, + wasFavorite ? 'Стикерпак удалён' : 'Стикерпак добавлен', + ); + } else { + showCustomNotification(context, 'Не удалось выполнить действие'); + } + } catch (e) { + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _copyLink(StickerSet set) { + final link = set.link; + if (link == null || link.isEmpty) { + showCustomNotification(context, 'Ссылка недоступна'); + return; + } + Clipboard.setData(ClipboardData(text: link)); + showCustomNotification(context, 'Ссылка скопирована'); + } + + Future _forward(StickerSet set) async { + final link = set.link; + if (link == null || link.isEmpty) { + showCustomNotification(context, 'Ссылка недоступна'); + return; + } + final profile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + final target = await showForwardPicker( + context: context, + accountId: profile?.id ?? 0, + ); + if (target == null || !mounted) return; + final ok = await messagesModule.sendLinkMessage(target.chatId, link); + if (!mounted) return; + showCustomNotification( + context, + ok ? 'Переслано в «${target.name}»' : 'Не удалось переслать', + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final height = MediaQuery.sizeOf(context).height * 0.78; + + return Container( + height: height, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 10, bottom: 6), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Expanded(child: _buildBody(cs)), + ], + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_loading) { + return Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary), + ), + ); + } + final set = _set; + if (_error != null || set == null) { + return Center( + child: Text( + 'Стикерпак недоступен', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ); + } + return Column( + children: [ + _buildHeader(cs, set), + Expanded(child: _buildGrid(cs, set)), + _buildActionButton(cs), + ], + ); + } + + Widget _buildHeader(ColorScheme cs, StickerSet set) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 8, 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + set.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + _pluralStickers(set.stickerIds.length), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + ), + ), + _buildMenu(cs, set), + ], + ), + ); + } + + Widget _buildMenu(ColorScheme cs, StickerSet set) { + return PopupMenuButton<_PackAction>( + icon: Icon(Symbols.more_horiz, color: cs.onSurfaceVariant), + color: cs.surfaceContainerHighest, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + onSelected: (action) { + switch (action) { + case _PackAction.forward: + _forward(set); + case _PackAction.copyLink: + _copyLink(set); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: _PackAction.forward, + child: Row( + children: [ + Icon(Symbols.forward, size: 20, color: cs.onSurface), + const SizedBox(width: 12), + const Text('Переслать'), + ], + ), + ), + PopupMenuItem( + value: _PackAction.copyLink, + child: Row( + children: [ + Icon(Symbols.link, size: 20, color: cs.onSurface), + const SizedBox(width: 12), + const Text('Скопировать ссылку'), + ], + ), + ), + ], + ); + } + + Widget _buildGrid(ColorScheme cs, StickerSet set) { + return GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + ), + itemCount: set.stickerIds.length, + itemBuilder: (context, i) { + final item = stickersModule.cachedSticker(set.stickerIds[i]); + if (item == null || item.url.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.all(6), + child: CachedNetworkImage( + imageUrl: item.url, + fit: BoxFit.contain, + memCacheWidth: 220, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => DecoratedBox( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(12), + ), + ), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ); + }, + ); + } + + Widget _buildActionButton(ColorScheme cs) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + child: SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: _isFavorite + ? cs.surfaceContainerHighest + : cs.primary, + foregroundColor: _isFavorite ? cs.onSurface : cs.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _busy ? null : _toggle, + child: _busy + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: _isFavorite ? cs.onSurface : cs.onPrimary, + ), + ) + : Text( + _isFavorite ? 'Убрать' : 'Добавить', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } + + String _pluralStickers(int n) { + final mod100 = n % 100; + final mod10 = n % 10; + if (mod100 >= 11 && mod100 <= 14) return '$n стикеров'; + if (mod10 == 1) return '$n стикер'; + if (mod10 >= 2 && mod10 <= 4) return '$n стикера'; + return '$n стикеров'; + } +}