feat: вродь диплинки и /start
This commit is contained in:
@@ -412,11 +412,19 @@ class CachedMessage {
|
|||||||
this.editHistory,
|
this.editHistory,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isBotStartMarker {
|
ControlAttachment? get controlAttachment =>
|
||||||
final control = attachments?.whereType<ControlAttachment>().firstOrNull;
|
attachments?.whereType<ControlAttachment>().firstOrNull;
|
||||||
return control != null && control.isBotStart;
|
|
||||||
|
String? get botStartPayload {
|
||||||
|
final control = controlAttachment;
|
||||||
|
if (control == null || !control.isBotStart) return null;
|
||||||
|
final payload = (control.startPayload ?? text)?.trim();
|
||||||
|
return (payload == null || payload.isEmpty) ? null : payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get isSilentBotStart =>
|
||||||
|
(controlAttachment?.isBotStart ?? false) && botStartPayload == null;
|
||||||
|
|
||||||
CachedMessage copyWith({
|
CachedMessage copyWith({
|
||||||
String? status,
|
String? status,
|
||||||
bool? deleted,
|
bool? deleted,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../frontend/debug/log_export.dart';
|
|||||||
import '../../frontend/widgets/max_link_handler.dart';
|
import '../../frontend/widgets/max_link_handler.dart';
|
||||||
import '../../main.dart';
|
import '../../main.dart';
|
||||||
import 'desktop_url_scheme.dart';
|
import 'desktop_url_scheme.dart';
|
||||||
|
import 'max_link.dart';
|
||||||
|
|
||||||
class DeepLinkService {
|
class DeepLinkService {
|
||||||
DeepLinkService._();
|
DeepLinkService._();
|
||||||
@@ -73,7 +74,9 @@ class DeepLinkService {
|
|||||||
|
|
||||||
if (!_ready || context == null) return;
|
if (!_ready || context == null) return;
|
||||||
final pending = _pending;
|
final pending = _pending;
|
||||||
if (pending == null || api.state != SessionState.online) return;
|
if (pending == null) return;
|
||||||
|
final needsConnection = MaxLink.parse(pending)?.needsConnection ?? true;
|
||||||
|
if (needsConnection && api.state != SessionState.online) return;
|
||||||
_pending = null;
|
_pending = null;
|
||||||
tryHandleMaxLink(context, pending);
|
tryHandleMaxLink(context, pending);
|
||||||
}
|
}
|
||||||
|
|||||||
+257
-48
@@ -1,26 +1,18 @@
|
|||||||
enum MaxLinkKind { call, invite, user, content, public, auth, stickerSet }
|
enum MaxContentKind { public, invite, user, content }
|
||||||
|
|
||||||
class MaxLink {
|
sealed class MaxLink {
|
||||||
final MaxLinkKind kind;
|
const MaxLink();
|
||||||
final String url;
|
|
||||||
final String baseUrl;
|
|
||||||
final String? startPayload;
|
|
||||||
|
|
||||||
const MaxLink(this.kind, this.url, {String? baseUrl, this.startPayload})
|
bool get needsConnection => false;
|
||||||
: baseUrl = baseUrl ?? url;
|
|
||||||
|
|
||||||
static final RegExp _host = RegExp(
|
static final RegExp _schemeless = RegExp(
|
||||||
r'^https?://(?:www\.)?max\.ru/(.+)$',
|
r'^(?:www\.)?max\.ru(?:[/?#]|$)',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
static final RegExp _segment = RegExp(r'^[A-Za-z0-9_]+$');
|
static final RegExp _segment = RegExp(r'^[A-Za-z0-9_]+$');
|
||||||
|
|
||||||
static const Set<String> _reserved = {
|
static const Set<String> _reserved = {
|
||||||
'join',
|
|
||||||
'joincall',
|
|
||||||
'u',
|
|
||||||
'c',
|
|
||||||
'login',
|
'login',
|
||||||
'ps',
|
'ps',
|
||||||
'tos',
|
'tos',
|
||||||
@@ -32,49 +24,266 @@ class MaxLink {
|
|||||||
static bool isMaxLink(String url) => parse(url) != null;
|
static bool isMaxLink(String url) => parse(url) != null;
|
||||||
|
|
||||||
static MaxLink? parse(String input) {
|
static MaxLink? parse(String input) {
|
||||||
final url = input.trim();
|
final uri = _canonical(input);
|
||||||
final match = _host.firstMatch(url);
|
if (uri == null) return null;
|
||||||
if (match == null) return null;
|
|
||||||
|
|
||||||
final rest = match.group(1)!;
|
final segments = uri.pathSegments
|
||||||
final path = rest.split('?').first.split('#').first;
|
|
||||||
final segments = path
|
|
||||||
.split('/')
|
|
||||||
.where((s) => s.isNotEmpty)
|
.where((s) => s.isNotEmpty)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
if (segments.isEmpty) return null;
|
final params = uri.queryParameters;
|
||||||
|
final url = uri.replace(scheme: 'https', host: 'max.ru').toString();
|
||||||
|
|
||||||
switch (segments.first.toLowerCase()) {
|
if (segments.isEmpty) return _parseQueryOnly(params);
|
||||||
|
if (segments.first.startsWith(':')) {
|
||||||
|
return _parseRoute(segments, params, url);
|
||||||
|
}
|
||||||
|
return _parseContent(segments, params, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uri? _canonical(String input) {
|
||||||
|
var value = input.trim();
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
if (!value.contains('://')) {
|
||||||
|
if (!_schemeless.hasMatch(value)) return null;
|
||||||
|
value = 'https://$value';
|
||||||
|
}
|
||||||
|
final uri = Uri.tryParse(value);
|
||||||
|
if (uri == null) return null;
|
||||||
|
final scheme = uri.scheme.toLowerCase();
|
||||||
|
if (scheme != 'https' && scheme != 'http' && scheme != 'max') return null;
|
||||||
|
final host = uri.host.toLowerCase();
|
||||||
|
if (host != 'max.ru' && host != 'www.max.ru') return null;
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MaxLink _parseQueryOnly(Map<String, String> params) {
|
||||||
|
final userId = _idParam(params, 'uid');
|
||||||
|
if (userId != null) return MaxContactIdLink(userId);
|
||||||
|
final chatId = _idParam(params, 'cid');
|
||||||
|
if (chatId != null) return MaxChatIdLink(chatId);
|
||||||
|
return const MaxRootLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
static MaxLink _parseRoute(
|
||||||
|
List<String> segments,
|
||||||
|
Map<String, String> params,
|
||||||
|
String url,
|
||||||
|
) {
|
||||||
|
final route = segments.join('/').toLowerCase();
|
||||||
|
switch (route) {
|
||||||
case ':auth':
|
case ':auth':
|
||||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.auth, url) : null;
|
return MaxAuthLink(url);
|
||||||
case 'joincall':
|
case ':current':
|
||||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.call, url) : null;
|
return const MaxCurrentLink();
|
||||||
case 'join':
|
case ':share-self-out':
|
||||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.invite, url) : null;
|
return const MaxShareSelfLink();
|
||||||
case 'u':
|
case ':share':
|
||||||
return segments.length >= 2 ? MaxLink(MaxLinkKind.user, url) : null;
|
return MaxShareTextLink(params['text']?.trim() ?? '');
|
||||||
case 'c':
|
case ':folder':
|
||||||
return segments.length >= 3 ? MaxLink(MaxLinkKind.content, url) : null;
|
final id = params['id']?.trim();
|
||||||
case 'stickerset':
|
if (id != null && id.isNotEmpty) return MaxFolderLink(id);
|
||||||
return segments.length >= 2
|
}
|
||||||
? MaxLink(MaxLinkKind.stickerSet, url)
|
if (segments.length > 1 && segments.first.toLowerCase() == ':auth') {
|
||||||
: null;
|
return MaxAuthLink(url);
|
||||||
|
}
|
||||||
|
return MaxRouteLink(route, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MaxLink? _parseContent(
|
||||||
|
List<String> segments,
|
||||||
|
Map<String, String> params,
|
||||||
|
String url,
|
||||||
|
) {
|
||||||
|
final first = segments.first;
|
||||||
|
final lower = first.toLowerCase();
|
||||||
|
|
||||||
|
if (segments.length == 1) {
|
||||||
|
final name = _publicName(first);
|
||||||
|
if (name == null) return null;
|
||||||
|
final startApp = params['startapp'] ?? params['startApp'];
|
||||||
|
if (startApp != null && startApp.isNotEmpty) {
|
||||||
|
return MaxWebAppLink('https://max.ru/$name', startApp.split('&').first);
|
||||||
|
}
|
||||||
|
return MaxContentLink(
|
||||||
|
kind: MaxContentKind.public,
|
||||||
|
url: url,
|
||||||
|
baseUrl: 'https://max.ru/$name',
|
||||||
|
startPayload: _startPayload(params),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_reserved.contains(segments.first.toLowerCase())) return null;
|
if (segments.length == 2) {
|
||||||
if (!_segment.hasMatch(segments.first)) return null;
|
switch (lower) {
|
||||||
return MaxLink(
|
case 'stickerset':
|
||||||
MaxLinkKind.public,
|
return MaxStickerSetLink('$lower/${segments[1]}');
|
||||||
url,
|
case 'joincall':
|
||||||
baseUrl: 'https://max.ru/${segments.join('/')}',
|
return MaxCallLink(url);
|
||||||
startPayload: _startPayload(rest),
|
case 'join':
|
||||||
);
|
return MaxContentLink(
|
||||||
|
kind: MaxContentKind.invite,
|
||||||
|
url: url,
|
||||||
|
baseUrl: url,
|
||||||
|
);
|
||||||
|
case 'u':
|
||||||
|
return MaxContentLink(
|
||||||
|
kind: MaxContentKind.user,
|
||||||
|
url: url,
|
||||||
|
baseUrl: url,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final messageId = int.tryParse(segments[1]);
|
||||||
|
final name = _publicName(first);
|
||||||
|
if (messageId == null || name == null) return null;
|
||||||
|
return MaxContentLink(
|
||||||
|
kind: MaxContentKind.public,
|
||||||
|
url: url,
|
||||||
|
baseUrl: 'https://max.ru/$name',
|
||||||
|
messageId: messageId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower == 'c' && segments.length == 3) {
|
||||||
|
final chatId = int.tryParse(segments[1]);
|
||||||
|
final messageId = int.tryParse(segments[2]);
|
||||||
|
if (chatId == null || messageId == null) return null;
|
||||||
|
return MaxContentLink(
|
||||||
|
kind: MaxContentKind.content,
|
||||||
|
url: url,
|
||||||
|
baseUrl: 'https://max.ru/c/$chatId',
|
||||||
|
messageId: messageId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower == 'join') {
|
||||||
|
return MaxContentLink(
|
||||||
|
kind: MaxContentKind.invite,
|
||||||
|
url: url,
|
||||||
|
baseUrl: url,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _startPayload(String rest) {
|
static String? _publicName(String segment) {
|
||||||
final parts = rest.split('#').first.split('?');
|
final name = segment.startsWith('@') ? segment.substring(1) : segment;
|
||||||
if (parts.length < 2) return null;
|
if (name.isEmpty) return null;
|
||||||
final value = Uri.splitQueryString(parts[1])['start']?.trim();
|
if (_reserved.contains(name.toLowerCase())) return null;
|
||||||
|
if (!_segment.hasMatch(name)) return null;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _startPayload(Map<String, String> params) {
|
||||||
|
final value = params['start']?.trim();
|
||||||
return (value == null || value.isEmpty) ? null : value;
|
return (value == null || value.isEmpty) ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int? _idParam(Map<String, String> params, String key) {
|
||||||
|
final raw = params[key]?.trim();
|
||||||
|
if (raw == null || raw.isEmpty) return null;
|
||||||
|
final value = int.tryParse(raw);
|
||||||
|
return (value == null || value <= 0) ? null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxRootLink extends MaxLink {
|
||||||
|
const MaxRootLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxCurrentLink extends MaxLink {
|
||||||
|
const MaxCurrentLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxShareSelfLink extends MaxLink {
|
||||||
|
const MaxShareSelfLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxAuthLink extends MaxLink {
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
const MaxAuthLink(this.url);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get needsConnection => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxCallLink extends MaxLink {
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
const MaxCallLink(this.url);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get needsConnection => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxStickerSetLink extends MaxLink {
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
const MaxStickerSetLink(this.path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get needsConnection => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxShareTextLink extends MaxLink {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const MaxShareTextLink(this.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxFolderLink extends MaxLink {
|
||||||
|
final String folderId;
|
||||||
|
|
||||||
|
const MaxFolderLink(this.folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxRouteLink extends MaxLink {
|
||||||
|
final String route;
|
||||||
|
final Map<String, String> params;
|
||||||
|
|
||||||
|
const MaxRouteLink(this.route, this.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxContactIdLink extends MaxLink {
|
||||||
|
final int userId;
|
||||||
|
|
||||||
|
const MaxContactIdLink(this.userId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get needsConnection => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxChatIdLink extends MaxLink {
|
||||||
|
final int chatId;
|
||||||
|
final int? messageId;
|
||||||
|
|
||||||
|
const MaxChatIdLink(this.chatId, {this.messageId});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxWebAppLink extends MaxLink {
|
||||||
|
final String url;
|
||||||
|
final String startApp;
|
||||||
|
|
||||||
|
const MaxWebAppLink(this.url, this.startApp);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get needsConnection => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MaxContentLink extends MaxLink {
|
||||||
|
final MaxContentKind kind;
|
||||||
|
final String url;
|
||||||
|
final String baseUrl;
|
||||||
|
final String? startPayload;
|
||||||
|
final int? messageId;
|
||||||
|
|
||||||
|
const MaxContentLink({
|
||||||
|
required this.kind,
|
||||||
|
required this.url,
|
||||||
|
required this.baseUrl,
|
||||||
|
this.startPayload,
|
||||||
|
this.messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get needsConnection => true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,36 @@ class ChatListScreen extends StatefulWidget {
|
|||||||
this.archiveMode = false,
|
this.archiveMode = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static _ChatListScreenState? _root;
|
||||||
|
|
||||||
|
static bool selectTab(int index) {
|
||||||
|
final root = _root;
|
||||||
|
if (root == null || !root.mounted) return false;
|
||||||
|
root._onNavTabSelected(index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool selectFolder(String folderId) {
|
||||||
|
final root = _root;
|
||||||
|
if (root == null || !root.mounted) return false;
|
||||||
|
root._onNavTabSelected(0);
|
||||||
|
return root._selectFolder(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool openSavedMessages() {
|
||||||
|
final root = _root;
|
||||||
|
if (root == null || !root.mounted) return false;
|
||||||
|
root._openSavedMessages();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool openSearch() {
|
||||||
|
final root = _root;
|
||||||
|
if (root == null || !root.mounted) return false;
|
||||||
|
root._openSearch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatListScreen> createState() => _ChatListScreenState();
|
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||||
}
|
}
|
||||||
@@ -553,6 +583,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
if (!widget.forwardMode && !widget.archiveMode) ChatListScreen._root = this;
|
||||||
_fabController = AnimationController(
|
_fabController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
@@ -1248,6 +1279,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (ChatListScreen._root == this) ChatListScreen._root = null;
|
||||||
appRouteObserver.unsubscribe(this);
|
appRouteObserver.unsubscribe(this);
|
||||||
_settleTimer?.cancel();
|
_settleTimer?.cancel();
|
||||||
chats.chatsChanged.removeListener(_onChatsChanged);
|
chats.chatsChanged.removeListener(_onChatsChanged);
|
||||||
@@ -1575,12 +1607,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 3, 20, 8),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: widget.forwardMode
|
onTap: widget.forwardMode ? null : _openSearch,
|
||||||
? null
|
|
||||||
: () => pushSwipeable(
|
|
||||||
context,
|
|
||||||
(_) => const SearchScreen(),
|
|
||||||
),
|
|
||||||
child: GlossyPill(
|
child: GlossyPill(
|
||||||
color: cs.surfaceContainerHighest,
|
color: cs.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
@@ -2543,28 +2570,31 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
return f.title;
|
return f.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _selectFolder(String folderId) {
|
||||||
|
final target = _folders.indexWhere((f) => f.id == folderId);
|
||||||
|
if (target < 0) return false;
|
||||||
|
setState(() => _selectedFolderId = folderId);
|
||||||
|
if (_folderPageController.hasClients) {
|
||||||
|
final cur = _folderPageController.page?.round() ?? 0;
|
||||||
|
if (cur == target) return true;
|
||||||
|
if ((target - cur).abs() > 1) {
|
||||||
|
final neighbor = target > cur ? target - 1 : target + 1;
|
||||||
|
_folderPageController.jumpToPage(neighbor);
|
||||||
|
}
|
||||||
|
_folderPageController.animateToPage(
|
||||||
|
target,
|
||||||
|
duration: const Duration(milliseconds: 280),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildFolderChip(String title, {required String folderId}) {
|
Widget _buildFolderChip(String title, {required String folderId}) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final isSelected = _selectedFolderId == folderId;
|
final isSelected = _selectedFolderId == folderId;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () => _selectFolder(folderId),
|
||||||
final target = _folders.indexWhere((f) => f.id == folderId);
|
|
||||||
if (target < 0) return;
|
|
||||||
setState(() => _selectedFolderId = folderId);
|
|
||||||
if (_folderPageController.hasClients) {
|
|
||||||
final cur = _folderPageController.page?.round() ?? 0;
|
|
||||||
if (cur == target) return;
|
|
||||||
if ((target - cur).abs() > 1) {
|
|
||||||
final neighbor = target > cur ? target - 1 : target + 1;
|
|
||||||
_folderPageController.jumpToPage(neighbor);
|
|
||||||
}
|
|
||||||
_folderPageController.animateToPage(
|
|
||||||
target,
|
|
||||||
duration: const Duration(milliseconds: 280),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: GlossyPill(
|
child: GlossyPill(
|
||||||
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
@@ -3190,6 +3220,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openSearch() =>
|
||||||
|
unawaited(pushSwipeable(context, (_) => const SearchScreen()));
|
||||||
|
|
||||||
void _openSavedMessages() {
|
void _openSavedMessages() {
|
||||||
CachedChat? self;
|
CachedChat? self;
|
||||||
for (final c in _chats) {
|
for (final c in _chats) {
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ class ChatScreen extends StatefulWidget {
|
|||||||
final String? commentPostId;
|
final String? commentPostId;
|
||||||
final CachedMessage? postMessage;
|
final CachedMessage? postMessage;
|
||||||
final String? botStartPayload;
|
final String? botStartPayload;
|
||||||
|
final String? initialText;
|
||||||
|
|
||||||
const ChatScreen({
|
const ChatScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -259,6 +260,7 @@ class ChatScreen extends StatefulWidget {
|
|||||||
this.commentPostId,
|
this.commentPostId,
|
||||||
this.postMessage,
|
this.postMessage,
|
||||||
this.botStartPayload,
|
this.botStartPayload,
|
||||||
|
this.initialText,
|
||||||
});
|
});
|
||||||
|
|
||||||
static final List<_ChatScreenState> _open = [];
|
static final List<_ChatScreenState> _open = [];
|
||||||
@@ -2058,7 +2060,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (_myId == 0 || _commentsMode || _messageController.text.isNotEmpty) {
|
if (_myId == 0 || _commentsMode || _messageController.text.isNotEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final draft = DraftStore.instance.get(_myId, widget.chatId);
|
final shared = widget.initialText?.trim();
|
||||||
|
final draft = (shared != null && shared.isNotEmpty)
|
||||||
|
? shared
|
||||||
|
: DraftStore.instance.get(_myId, widget.chatId);
|
||||||
if (draft == null || draft.isEmpty) return;
|
if (draft == null || draft.isEmpty) return;
|
||||||
_messageController.text = draft;
|
_messageController.text = draft;
|
||||||
_messageController.selection = TextSelection.collapsed(
|
_messageController.selection = TextSelection.collapsed(
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ class _ControlBubbleState extends State<ControlBubble> {
|
|||||||
sender,
|
sender,
|
||||||
const _ControlSegment(' закрепил(а) сообщение'),
|
const _ControlSegment(' закрепил(а) сообщение'),
|
||||||
], senderId);
|
], senderId);
|
||||||
|
case ControlAttachment.botStartedEvent:
|
||||||
|
final payload = widget.message.botStartPayload;
|
||||||
|
return _ControlText([
|
||||||
|
const _ControlSegment('Бот запущен'),
|
||||||
|
if (payload != null) _ControlSegment(': $payload'),
|
||||||
|
], null);
|
||||||
default:
|
default:
|
||||||
return _ControlText([
|
return _ControlText([
|
||||||
_ControlSegment(control.title ?? ''),
|
_ControlSegment(control.title ?? ''),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import '../../backend/modules/chats.dart';
|
import '../../backend/modules/chats.dart';
|
||||||
import '../../backend/modules/links.dart';
|
import '../../backend/modules/links.dart';
|
||||||
|
import '../../core/cache/info_cache.dart';
|
||||||
import '../../core/links/max_link.dart';
|
import '../../core/links/max_link.dart';
|
||||||
import '../../core/storage/app_database.dart';
|
import '../../core/storage/app_database.dart';
|
||||||
import '../../main.dart';
|
import '../../main.dart';
|
||||||
@@ -12,7 +14,8 @@ import '../screens/contacts/open_contact_profile.dart';
|
|||||||
import 'call_link_handler.dart';
|
import 'call_link_handler.dart';
|
||||||
import 'confirm_dialog.dart';
|
import 'confirm_dialog.dart';
|
||||||
import 'custom_notification.dart';
|
import 'custom_notification.dart';
|
||||||
import 'sticker_pack_sheet.dart';
|
import 'max_link_nav.dart';
|
||||||
|
import 'max_route_handler.dart';
|
||||||
import 'swipe_route.dart';
|
import 'swipe_route.dart';
|
||||||
import 'web_qr_login.dart';
|
import 'web_qr_login.dart';
|
||||||
|
|
||||||
@@ -20,20 +23,47 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
|
|||||||
final link = MaxLink.parse(url);
|
final link = MaxLink.parse(url);
|
||||||
if (link == null) return false;
|
if (link == null) return false;
|
||||||
|
|
||||||
if (link.kind == MaxLinkKind.call) {
|
switch (link) {
|
||||||
return tryHandleCallLink(context, url);
|
case MaxRootLink():
|
||||||
|
popToAppRoot(context);
|
||||||
|
return true;
|
||||||
|
case MaxCurrentLink():
|
||||||
|
return true;
|
||||||
|
case MaxAuthLink(:final url):
|
||||||
|
await confirmAndAuthorizeWebQrLogin(context, url);
|
||||||
|
return true;
|
||||||
|
case MaxCallLink(:final url):
|
||||||
|
return tryHandleCallLink(context, url);
|
||||||
|
case MaxStickerSetLink(:final path):
|
||||||
|
return openStickerSetByPath(context, path);
|
||||||
|
case MaxShareSelfLink():
|
||||||
|
return _shareOwnLink(context);
|
||||||
|
case MaxShareTextLink(:final text):
|
||||||
|
return shareTextToChat(context, text);
|
||||||
|
case MaxFolderLink(:final folderId):
|
||||||
|
return openFolderChatList(context, folderId);
|
||||||
|
case MaxRouteLink(:final route, :final params):
|
||||||
|
return openMaxRoute(context, route, params);
|
||||||
|
case MaxContactIdLink(:final userId):
|
||||||
|
return openContactById(context, userId);
|
||||||
|
case MaxChatIdLink(:final chatId, :final messageId):
|
||||||
|
return openChatById(context, chatId, messageId: messageId);
|
||||||
|
case MaxWebAppLink():
|
||||||
|
return _openWebAppLink(context, link);
|
||||||
|
case MaxContentLink():
|
||||||
|
return _openContentLink(context, link);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (link.kind == MaxLinkKind.auth) {
|
Future<ResolvedLink?> _resolve(String url, String baseUrl) async {
|
||||||
await confirmAndAuthorizeWebQrLogin(context, link.url);
|
final resolved = await LinkModule.resolve(api, url);
|
||||||
return true;
|
if (baseUrl == url) return resolved;
|
||||||
}
|
if (resolved is ResolvedChat || resolved is ResolvedUser) return resolved;
|
||||||
|
return LinkModule.resolve(api, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
if (link.kind == MaxLinkKind.stickerSet) {
|
Future<bool> _openContentLink(BuildContext context, MaxContentLink link) async {
|
||||||
return _openStickerSet(context, link.url);
|
final resolved = await _resolve(link.url, link.baseUrl);
|
||||||
}
|
|
||||||
|
|
||||||
final resolved = await _resolve(link);
|
|
||||||
if (!context.mounted) return true;
|
if (!context.mounted) return true;
|
||||||
|
|
||||||
switch (resolved) {
|
switch (resolved) {
|
||||||
@@ -51,36 +81,62 @@ Future<bool> tryHandleMaxLink(BuildContext context, String url) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ResolvedLink?> _resolve(MaxLink link) async {
|
Future<bool> _openWebAppLink(BuildContext context, MaxWebAppLink link) async {
|
||||||
final resolved = await LinkModule.resolve(api, link.url);
|
final resolved = await _resolve(link.url, link.url);
|
||||||
if (link.startPayload == null || link.baseUrl == link.url) return resolved;
|
|
||||||
if (resolved is ResolvedChat || resolved is ResolvedUser) return resolved;
|
|
||||||
return LinkModule.resolve(api, link.baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _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 (!context.mounted) return true;
|
||||||
if (set == null) {
|
|
||||||
showCustomNotification(context, 'Стикерпак недоступен');
|
final botId = await _botIdOf(resolved);
|
||||||
|
if (!context.mounted) return true;
|
||||||
|
if (botId == null) {
|
||||||
|
final message = resolved is ResolvedLinkError
|
||||||
|
? resolved.message
|
||||||
|
: 'Не удалось открыть приложение';
|
||||||
|
showCustomNotification(context, message);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await showStickerPackSheet(context, knownSetId: set.id);
|
return openWebAppForBot(context, botId, startParam: link.startApp);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int?> _botIdOf(ResolvedLink? resolved) async {
|
||||||
|
switch (resolved) {
|
||||||
|
case ResolvedUser(:final contact):
|
||||||
|
final id = contact['id'];
|
||||||
|
return id is int ? id : null;
|
||||||
|
case ResolvedChat(:final chat):
|
||||||
|
final chatId = chat['id'];
|
||||||
|
if (chatId is! int) return null;
|
||||||
|
if ((chat['type'] as String?) != 'DIALOG') return null;
|
||||||
|
final myId = await currentAccountId();
|
||||||
|
return myId == 0 ? null : chatId ^ myId;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _shareOwnLink(BuildContext context) async {
|
||||||
|
final myId = await currentAccountId();
|
||||||
|
if (myId == 0) return false;
|
||||||
|
final info = await ContactInfoFetch.get(myId);
|
||||||
|
if (!context.mounted) return true;
|
||||||
|
|
||||||
|
final link = (info?.raw['link'] as String?)?.trim();
|
||||||
|
if (link == null || link.isEmpty) {
|
||||||
|
showCustomNotification(context, 'У профиля нет публичной ссылки');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Share.share(link);
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
showCustomNotification(context, 'Не удалось поделиться ссылкой');
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openContact(
|
Future<void> _openContact(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MaxLink link,
|
MaxContentLink link,
|
||||||
Map<dynamic, dynamic> contact,
|
Map<dynamic, dynamic> contact,
|
||||||
) async {
|
) async {
|
||||||
final id = contact['id'];
|
final id = contact['id'];
|
||||||
@@ -112,8 +168,7 @@ Future<bool> _startBotDialog(
|
|||||||
Map<dynamic, dynamic> contact,
|
Map<dynamic, dynamic> contact,
|
||||||
String startPayload,
|
String startPayload,
|
||||||
) async {
|
) async {
|
||||||
final profile = await AppDatabase.loadActiveProfile();
|
final myId = await currentAccountId();
|
||||||
final myId = profile?.id ?? 0;
|
|
||||||
if (myId == 0) return false;
|
if (myId == 0) return false;
|
||||||
|
|
||||||
final chatId =
|
final chatId =
|
||||||
@@ -155,7 +210,7 @@ void _openChatAndStartBot(
|
|||||||
|
|
||||||
Future<void> _openResolvedChat(
|
Future<void> _openResolvedChat(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MaxLink link,
|
MaxContentLink link,
|
||||||
ResolvedChat resolved,
|
ResolvedChat resolved,
|
||||||
) async {
|
) async {
|
||||||
final chat = resolved.chat;
|
final chat = resolved.chat;
|
||||||
@@ -170,14 +225,15 @@ Future<void> _openResolvedChat(
|
|||||||
final icon = (chat['baseIconUrl'] as String?) ?? '';
|
final icon = (chat['baseIconUrl'] as String?) ?? '';
|
||||||
final access = chat['access'];
|
final access = chat['access'];
|
||||||
|
|
||||||
final profile = await AppDatabase.loadActiveProfile();
|
final myId = await currentAccountId();
|
||||||
final myId = profile?.id ?? 0;
|
|
||||||
var isMember = myId != 0 && await AppDatabase.isChatInList(myId, id);
|
var isMember = myId != 0 && await AppDatabase.isChatInList(myId, id);
|
||||||
|
|
||||||
await chats.cacheServerChat(chat, myId, inList: isMember);
|
await chats.cacheServerChat(chat, myId, inList: isMember);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
if (link.kind == MaxLinkKind.invite && access == 'PRIVATE' && !isMember) {
|
if (link.kind == MaxContentKind.invite &&
|
||||||
|
access == 'PRIVATE' &&
|
||||||
|
!isMember) {
|
||||||
final label = title.isEmpty ? 'этот чат' : '«$title»';
|
final label = title.isEmpty ? 'этот чат' : '«$title»';
|
||||||
final confirmed = await showConfirmDialog(
|
final confirmed = await showConfirmDialog(
|
||||||
context,
|
context,
|
||||||
@@ -210,6 +266,7 @@ Future<void> _openResolvedChat(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final target = _messageTarget(link, resolved.message);
|
||||||
pushSwipeable(
|
pushSwipeable(
|
||||||
context,
|
context,
|
||||||
(_) => ChatScreen(
|
(_) => ChatScreen(
|
||||||
@@ -218,10 +275,26 @@ Future<void> _openResolvedChat(
|
|||||||
imageUrl: icon,
|
imageUrl: icon,
|
||||||
chatType: type,
|
chatType: type,
|
||||||
channelSubscribed: type == 'CHANNEL' ? isMember : null,
|
channelSubscribed: type == 'CHANNEL' ? isMember : null,
|
||||||
|
initialMessageId: target?.id,
|
||||||
|
initialMessageTime: target?.time,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
({String id, int? time})? _messageTarget(
|
||||||
|
MaxContentLink link,
|
||||||
|
Map<dynamic, dynamic>? message,
|
||||||
|
) {
|
||||||
|
final serverId = message?['id']?.toString();
|
||||||
|
final time = message?['time'];
|
||||||
|
if (serverId != null && serverId.isNotEmpty) {
|
||||||
|
return (id: serverId, time: time is int ? time : null);
|
||||||
|
}
|
||||||
|
final messageId = link.messageId;
|
||||||
|
if (messageId == null) return null;
|
||||||
|
return (id: messageId.toString(), time: null);
|
||||||
|
}
|
||||||
|
|
||||||
String _contactName(Map<dynamic, dynamic> contact) {
|
String _contactName(Map<dynamic, dynamic> contact) {
|
||||||
final names = contact['names'];
|
final names = contact['names'];
|
||||||
if (names is List && names.isNotEmpty && names.first is Map) {
|
if (names is List && names.isNotEmpty && names.first is Map) {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../backend/modules/chats.dart';
|
||||||
|
import '../../backend/modules/messages.dart' show ContactCache;
|
||||||
|
import '../../core/cache/info_cache.dart';
|
||||||
|
import '../../core/storage/app_database.dart';
|
||||||
|
import '../../core/utils/webview_support.dart';
|
||||||
|
import '../../main.dart';
|
||||||
|
import '../screens/chats/chat_list_screen.dart';
|
||||||
|
import '../screens/chats/chat_screen.dart';
|
||||||
|
import '../screens/contacts/open_contact_profile.dart';
|
||||||
|
import '../screens/webapp/web_app_screen.dart';
|
||||||
|
import 'custom_notification.dart';
|
||||||
|
import 'sticker_pack_sheet.dart';
|
||||||
|
import 'swipe_route.dart';
|
||||||
|
|
||||||
|
void popToAppRoot(BuildContext context) {
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BuildContext?> popToAppRootAndSettle(BuildContext context) async {
|
||||||
|
popToAppRoot(context);
|
||||||
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
|
return KometApp.navigatorKey.currentContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> currentAccountId() async {
|
||||||
|
final profile = await AppDatabase.loadActiveProfile();
|
||||||
|
return profile?.id ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CachedChat?> resolveChat(int myId, int chatId) async {
|
||||||
|
var rows = await chats.getChat(myId, chatId);
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
await chats.ensureChatCached(api, myId, chatId);
|
||||||
|
rows = await chats.getChat(myId, chatId);
|
||||||
|
}
|
||||||
|
return rows.isEmpty ? null : rows.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openChatById(
|
||||||
|
BuildContext context,
|
||||||
|
int chatId, {
|
||||||
|
int? messageId,
|
||||||
|
int? messageTime,
|
||||||
|
String? initialText,
|
||||||
|
}) async {
|
||||||
|
final myId = await currentAccountId();
|
||||||
|
if (myId == 0) return false;
|
||||||
|
final chat = await resolveChat(myId, chatId);
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
if (chat == null) {
|
||||||
|
showCustomNotification(context, 'Чат не найден');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final title = chat.title?.trim();
|
||||||
|
final name = (title != null && title.isNotEmpty)
|
||||||
|
? title
|
||||||
|
: (ContactCache.get(chatId ^ myId) ?? 'Чат');
|
||||||
|
|
||||||
|
await pushSwipeable(
|
||||||
|
context,
|
||||||
|
(_) => ChatScreen(
|
||||||
|
chatId: chatId,
|
||||||
|
name: name,
|
||||||
|
imageUrl: chat.iconUrl ?? '',
|
||||||
|
chatType: chat.type,
|
||||||
|
initialMessageId: messageId?.toString(),
|
||||||
|
initialMessageTime: messageTime,
|
||||||
|
initialText: initialText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openContactById(BuildContext context, int userId) async {
|
||||||
|
final info = await ContactInfoFetch.get(userId);
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
await openContactDialogProfile(
|
||||||
|
context,
|
||||||
|
contactId: userId,
|
||||||
|
name: ContactCache.get(userId) ?? info?.displayName ?? 'Профиль',
|
||||||
|
avatarUrl: info?.avatarUrl,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openStickerSetByPath(BuildContext context, String path) async {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openStickerSetById(BuildContext context, int setId) async {
|
||||||
|
await showStickerPackSheet(context, knownSetId: setId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openWebAppForBot(
|
||||||
|
BuildContext context,
|
||||||
|
int botId, {
|
||||||
|
String? startParam,
|
||||||
|
int? chatId,
|
||||||
|
}) async {
|
||||||
|
if (!webViewSupported) {
|
||||||
|
showCustomNotification(context, 'На вашей платформе это недоступно');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final myId = await currentAccountId();
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
final dialogId = chatId ?? (myId == 0 ? null : myId ^ botId);
|
||||||
|
final title = ContactCache.get(botId) ?? 'Приложение';
|
||||||
|
|
||||||
|
await pushSwipeable(
|
||||||
|
context,
|
||||||
|
(_) => WebAppScreen(
|
||||||
|
title: title,
|
||||||
|
loader: () => webAppModule.fetchLaunch(
|
||||||
|
botId,
|
||||||
|
startParam: startParam,
|
||||||
|
chatId: dialogId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> shareTextToChat(BuildContext context, String text) async {
|
||||||
|
if (text.isEmpty) {
|
||||||
|
showCustomNotification(context, 'Нечего отправлять');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final target = await openForwardScreen(context: context);
|
||||||
|
if (target == null || !context.mounted) return true;
|
||||||
|
|
||||||
|
await pushSwipeable(
|
||||||
|
context,
|
||||||
|
(_) => ChatScreen(
|
||||||
|
chatId: target.chatId,
|
||||||
|
name: target.name,
|
||||||
|
imageUrl: target.imageUrl,
|
||||||
|
chatType: target.chatType,
|
||||||
|
initialText: text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openFolderChatList(BuildContext context, String folderId) async {
|
||||||
|
final root = await popToAppRootAndSettle(context);
|
||||||
|
if (ChatListScreen.selectFolder(folderId)) return true;
|
||||||
|
if (root != null) showCustomNotification(root, 'Папка не найдена');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openRootTab(BuildContext context, int index) async {
|
||||||
|
final root = await popToAppRootAndSettle(context);
|
||||||
|
if (ChatListScreen.selectTab(index)) return true;
|
||||||
|
if (root != null) notifyNeedsAccount(root);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void notifyNeedsAccount(BuildContext context) =>
|
||||||
|
showCustomNotification(context, 'Сначала войдите в аккаунт');
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/link_opener.dart';
|
||||||
|
import '../screens/chats/chat_info_screen.dart';
|
||||||
|
import '../screens/chats/chat_list_screen.dart';
|
||||||
|
import '../screens/chats/scheduled_messages_screen.dart';
|
||||||
|
import '../screens/profile/appearance_screen.dart';
|
||||||
|
import '../screens/profile/debug_menu_screen.dart';
|
||||||
|
import '../screens/profile/devices_screen.dart';
|
||||||
|
import '../screens/profile/edit_profile_screen.dart';
|
||||||
|
import '../screens/profile/info_screen.dart';
|
||||||
|
import '../screens/profile/message_actions_screen.dart';
|
||||||
|
import '../screens/profile/notifications_screen.dart';
|
||||||
|
import '../screens/profile/security_screen.dart';
|
||||||
|
import '../screens/profile/web_qr_scan_screen.dart';
|
||||||
|
import 'custom_notification.dart';
|
||||||
|
import 'max_link_nav.dart';
|
||||||
|
import 'swipe_route.dart';
|
||||||
|
|
||||||
|
Future<bool> openMaxRoute(
|
||||||
|
BuildContext context,
|
||||||
|
String route,
|
||||||
|
Map<String, String> params,
|
||||||
|
) async {
|
||||||
|
switch (route) {
|
||||||
|
case ':chat-list':
|
||||||
|
case ':settings/folder-list':
|
||||||
|
return openRootTab(context, 0);
|
||||||
|
case ':calls-history':
|
||||||
|
case ':call-list':
|
||||||
|
return openRootTab(context, 1);
|
||||||
|
case ':contact-list':
|
||||||
|
return openRootTab(context, 2);
|
||||||
|
case ':settings':
|
||||||
|
return openRootTab(context, 3);
|
||||||
|
|
||||||
|
case ':chats-search':
|
||||||
|
final root = await popToAppRootAndSettle(context);
|
||||||
|
if (ChatListScreen.openSearch()) return true;
|
||||||
|
if (root != null) notifyNeedsAccount(root);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ':saved-messages':
|
||||||
|
final root = await popToAppRootAndSettle(context);
|
||||||
|
if (ChatListScreen.openSavedMessages()) return true;
|
||||||
|
if (root != null) notifyNeedsAccount(root);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ':settings/folder':
|
||||||
|
final id = params['id']?.trim();
|
||||||
|
if (id == null || id.isEmpty) return _badLink(context, route);
|
||||||
|
return openFolderChatList(context, id);
|
||||||
|
|
||||||
|
case ':chats':
|
||||||
|
final id = _intOf(params['id']);
|
||||||
|
if (id == null) return _badLink(context, route);
|
||||||
|
return openChatById(context, id);
|
||||||
|
|
||||||
|
case ':profile':
|
||||||
|
case ':profile/members':
|
||||||
|
case ':profile/avatars':
|
||||||
|
final id = _intOf(params['id']);
|
||||||
|
if (id == null) return _badLink(context, route);
|
||||||
|
final type = (params['type'] ?? '').toUpperCase();
|
||||||
|
if (type == 'CHAT' || type == 'CHANNEL') {
|
||||||
|
return openChatInfoById(context, id);
|
||||||
|
}
|
||||||
|
return openContactById(context, id);
|
||||||
|
|
||||||
|
case ':profile/attaches':
|
||||||
|
final id = _intOf(params['id']);
|
||||||
|
if (id == null) return _badLink(context, route);
|
||||||
|
return openChatInfoById(context, id, initialTab: ChatInfoTab.media);
|
||||||
|
|
||||||
|
case ':profile/edit':
|
||||||
|
return _push(context, const EditProfileScreen());
|
||||||
|
|
||||||
|
case ':scheduled-messages':
|
||||||
|
final id = _intOf(params['id']);
|
||||||
|
if (id == null) return _badLink(context, route);
|
||||||
|
return openScheduledMessages(context, id);
|
||||||
|
|
||||||
|
case ':stickers/set':
|
||||||
|
final setId = _intOf(params['set_id']);
|
||||||
|
if (setId == null) return _badLink(context, route);
|
||||||
|
return openStickerSetById(context, setId);
|
||||||
|
|
||||||
|
case ':webapp:root':
|
||||||
|
final botId = _intOf(params['bot_id']);
|
||||||
|
if (botId == null) return _badLink(context, route);
|
||||||
|
return openWebAppForBot(
|
||||||
|
context,
|
||||||
|
botId,
|
||||||
|
startParam: params['entry_point'],
|
||||||
|
chatId: _intOf(params['chat_id']),
|
||||||
|
);
|
||||||
|
|
||||||
|
case ':settings/webapp':
|
||||||
|
final botId = _intOf(params['bot_id']);
|
||||||
|
if (botId == null) return _badLink(context, route);
|
||||||
|
return openWebAppForBot(context, botId);
|
||||||
|
|
||||||
|
case ':location/show':
|
||||||
|
final lat = double.tryParse(params['lat'] ?? '');
|
||||||
|
final lon = double.tryParse(params['lon'] ?? '');
|
||||||
|
if (lat == null || lon == null) return _badLink(context, route);
|
||||||
|
await openLocationOnMap(
|
||||||
|
context,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
zoom: double.tryParse(params['z'] ?? ''),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ':qr-scanner':
|
||||||
|
return _push(context, const WebQrScanScreen());
|
||||||
|
case ':settings/appearance':
|
||||||
|
return _push(context, const AppearanceScreen());
|
||||||
|
case ':settings/notifications':
|
||||||
|
case ':settings/notifications/chat':
|
||||||
|
case ':settings/notifications/dialog':
|
||||||
|
case ':settings/notifications/other':
|
||||||
|
return _push(context, const NotificationsScreen());
|
||||||
|
case ':settings/devices':
|
||||||
|
return _push(context, const DevicesScreen());
|
||||||
|
case ':settings/aboutapp':
|
||||||
|
return _push(context, const InfoScreen());
|
||||||
|
case ':settings/privacy':
|
||||||
|
case ':settings/privacy/pincode':
|
||||||
|
case ':settings/blacklist':
|
||||||
|
return _push(context, const SecurityScreen());
|
||||||
|
case ':settings/messages':
|
||||||
|
return _push(context, const MessageActionsScreen());
|
||||||
|
case ':settings/dev':
|
||||||
|
case ':settings/dev/logsviewer':
|
||||||
|
case ':settings/dev/memorydebugger':
|
||||||
|
case ':settings/dev/showroom':
|
||||||
|
case ':settings/dev/threadsviewer':
|
||||||
|
case ':settings/dev/integritylogsviewer':
|
||||||
|
return _push(context, const DebugMenuScreen());
|
||||||
|
|
||||||
|
case ':current':
|
||||||
|
case ':link-intercept':
|
||||||
|
case ':external_callback':
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
showCustomNotification(context, 'Ссылка не поддерживается: $route');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openChatInfoById(
|
||||||
|
BuildContext context,
|
||||||
|
int chatId, {
|
||||||
|
ChatInfoTab? initialTab,
|
||||||
|
}) async {
|
||||||
|
final myId = await currentAccountId();
|
||||||
|
if (myId == 0) return false;
|
||||||
|
final chat = await resolveChat(myId, chatId);
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
|
||||||
|
final title = chat?.title?.trim();
|
||||||
|
return _push(
|
||||||
|
context,
|
||||||
|
ChatInfoScreen(
|
||||||
|
chatId: chatId,
|
||||||
|
name: (title != null && title.isNotEmpty) ? title : 'Чат',
|
||||||
|
imageUrl: chat?.iconUrl ?? '',
|
||||||
|
chatType: chat?.type ?? 'CHAT',
|
||||||
|
initialTab: initialTab,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> openScheduledMessages(BuildContext context, int chatId) async {
|
||||||
|
final myId = await currentAccountId();
|
||||||
|
if (myId == 0) return false;
|
||||||
|
final chat = await resolveChat(myId, chatId);
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
|
||||||
|
final title = chat?.title?.trim();
|
||||||
|
return _push(
|
||||||
|
context,
|
||||||
|
ScheduledMessagesScreen(
|
||||||
|
chatId: chatId,
|
||||||
|
accountId: myId,
|
||||||
|
chatName: (title != null && title.isNotEmpty) ? title : 'Чат',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _push(BuildContext context, Widget screen) async {
|
||||||
|
await pushSwipeable(context, (_) => screen);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _badLink(BuildContext context, String route) {
|
||||||
|
showCustomNotification(context, 'Неполная ссылка: $route');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _intOf(String? raw) {
|
||||||
|
final value = int.tryParse(raw?.trim() ?? '');
|
||||||
|
return (value == null || value <= 0) ? null : value;
|
||||||
|
}
|
||||||
@@ -752,7 +752,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final contentType = _contentType;
|
final contentType = _contentType;
|
||||||
|
|
||||||
if (message.isControl) {
|
if (message.isControl) {
|
||||||
if (message.isBotStartMarker) return const SizedBox.shrink();
|
if (message.isSilentBotStart) return const SizedBox.shrink();
|
||||||
const controlShape = BubbleShape.singleMiddle;
|
const controlShape = BubbleShape.singleMiddle;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ class ControlAttachment extends MessageAttachment {
|
|||||||
final String? title;
|
final String? title;
|
||||||
final List<int>? userIds;
|
final List<int>? userIds;
|
||||||
final int? userId;
|
final int? userId;
|
||||||
|
final String? startPayload;
|
||||||
|
|
||||||
const ControlAttachment({
|
const ControlAttachment({
|
||||||
super.previewData,
|
super.previewData,
|
||||||
@@ -444,6 +445,7 @@ class ControlAttachment extends MessageAttachment {
|
|||||||
this.title,
|
this.title,
|
||||||
this.userIds,
|
this.userIds,
|
||||||
this.userId,
|
this.userId,
|
||||||
|
this.startPayload,
|
||||||
}) : super(type: AttachmentType.control);
|
}) : super(type: AttachmentType.control);
|
||||||
|
|
||||||
bool get isBotStart => event == botStartedEvent;
|
bool get isBotStart => event == botStartedEvent;
|
||||||
@@ -463,6 +465,7 @@ class ControlAttachment extends MessageAttachment {
|
|||||||
userId: map['userId'] is int
|
userId: map['userId'] is int
|
||||||
? map['userId'] as int
|
? map['userId'] as int
|
||||||
: int.tryParse(map['userId']?.toString() ?? ''),
|
: int.tryParse(map['userId']?.toString() ?? ''),
|
||||||
|
startPayload: map['startPayload']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +478,7 @@ class ControlAttachment extends MessageAttachment {
|
|||||||
'title': title,
|
'title': title,
|
||||||
'userIds': userIds,
|
'userIds': userIds,
|
||||||
'userId': userId,
|
'userId': userId,
|
||||||
|
'startPayload': startPayload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# Диплинки MAX
|
||||||
|
|
||||||
|
Разобрано из `ru.oneme.app.apk` — MAX 26.24.0 (versionCode 6784, minSdk 26).
|
||||||
|
Точки входа: `AndroidManifest.xml` → `one.me.android.deeplink.LinkInterceptorActivity`,
|
||||||
|
парсер `one.me.link.interceptor.b0.a()`, хелпер `ru.ok.messages.utils.a`,
|
||||||
|
реестр внутренних маршрутов `iz4` / `nz4` (`DeepLinkRoute`), навигатор `pz4`.
|
||||||
|
|
||||||
|
## Что вообще перехватывается
|
||||||
|
|
||||||
|
Один активити (`LinkInterceptorActivity`) с двумя intent-filter, `android:autoVerify="true"`:
|
||||||
|
|
||||||
|
| Схема | Хост | Путь |
|
||||||
|
|---|---|---|
|
||||||
|
| `https` | `max.ru` | `/..*` (минимум один символ после `/`) |
|
||||||
|
| `http` | `max.ru` | `/..*` |
|
||||||
|
| `max` | `max.ru` | любой |
|
||||||
|
|
||||||
|
`max://…` внутри сразу переписывается в `https://…` (`b0.k()`), так что дальше всё едино.
|
||||||
|
Важная деталь: `/..*` требует непустой путь, поэтому ссылки вида `https://max.ru/?uid=123`
|
||||||
|
из браузера в приложение не попадают — только через схему `max://`, где путь не ограничен.
|
||||||
|
|
||||||
|
Нормализация ссылки перед разбором (`ru.ok.messages.utils.a.e()`):
|
||||||
|
|
||||||
|
- завершающий `/` отбрасывается;
|
||||||
|
- строка без схемы → дописывается `https://` (**то есть `max.ru/xxx` — валидный диплинк**);
|
||||||
|
- начинается с `:` или `max://:` → внутренний маршрут (см. ниже);
|
||||||
|
- начинается с `@` → ник, ссылка не переписывается.
|
||||||
|
|
||||||
|
Общий порядок разбора в `b0.a()`: `:auth` → (не готов к работе → `OpenApp` с отложенной ссылкой)
|
||||||
|
→ `:current` → внутренний маршрут → корневая ссылка → `:share-self-out` → чужой хост → контентные ссылки.
|
||||||
|
|
||||||
|
## Контентные ссылки max.ru
|
||||||
|
|
||||||
|
| Ссылка | Что открывает | Результат в коде |
|
||||||
|
|---|---|---|
|
||||||
|
| `max.ru` (и `http://max.ru`, `https://max.ru`, `max://max.ru`, `max://max.ru/`) | просто открывает приложение | `DeepLinkData$OpenApp` → `OpenApp` |
|
||||||
|
| `max.ru/<username>` | чат/канал/бота по публичной ссылке; если такой чат уже локально есть — открывает его напрямую, иначе резолвит на сервере | `JoinLink` → `ShowChat` / `ShowContact` / `ConfirmJoin` |
|
||||||
|
| `max.ru/@<nickname>` | то же, ник ищется среди ссылок известных чатов | → чат либо `UnknownContact` |
|
||||||
|
| `max.ru/<username>?start=<payload>` | диалог с ботом + автозапуск: отправляется `botStarted` со `startPayload` | `ShowContactDialog(chatId, startPayload, externalCallback)` |
|
||||||
|
| `max.ru/<username>?startapp=<payload>` | мини-приложение бота (payload обрезается по первому `&`, query из ссылки вычищается) | `StartWebAppLink` → `OpenWebApp(botId, startParam)` / `ErrorWebAppNotExist` |
|
||||||
|
| `max.ru/join/<code>` | вступление по приватной инвайт-ссылке | `JoinLink` → `ConfirmJoin` / `ShowChat` |
|
||||||
|
| `max.ru/joincall/<code>` | экран входа в звонок по ссылке | `CallJoinLink` → `ShowJoinCall` |
|
||||||
|
| `max.ru/stickerset/<id>` | стикерпак; `id` берётся до первого `-` | `StickerSet` → `ShowStickerSet` |
|
||||||
|
| `max.ru/<username>/<messageId>` | чат на конкретном сообщении (второй сегмент должен быть числом) | `MessagestLink` → `ShowChat(chatId, messageId)` |
|
||||||
|
| `max.ru/c/<chatId>/<messageId>` | сообщение/пост в чате по числовым id | `MessagestLink` → `ShowChat` |
|
||||||
|
| `max.ru/:folder?id=<folderId>` | список чатов в папке | `FolderChatList` → `OpenChatListInFolder`, иначе `UnknownFolderError` |
|
||||||
|
| `max.ru/?uid=<userId>` | контакт/диалог по id пользователя (ищется локально) | `DeepLinkData(contactId)` |
|
||||||
|
| `max.ru/?cid=<chatId>` | чат по серверному id (ищется локально) | `DeepLinkData(chatId)` |
|
||||||
|
| `max.ru/:auth/<...>` | подтверждение веб-логина по QR; путь принудительно урезается до `https://max.ru/:auth` | маршрут `:auth` |
|
||||||
|
| `max.ru/:current` | остаётся на текущем экране (для внешних колбэков) | `OpenCurrent` |
|
||||||
|
| `https://max.ru/:share-self-out` | системный шэринг своей инвайт-ссылки | `OpenExternalSharingToInvite` |
|
||||||
|
| `max.ru/:share?text=<text>` | шэринг текста внутрь приложения (выбор чата) | маршрут `:share` |
|
||||||
|
| любой другой хост | открывается во внешнем браузере | `OpenBrowser` |
|
||||||
|
|
||||||
|
Ошибочные ветки: `ErrorBrokenLink`, `ErrorPrivateChat`, `ErrorPrivateChannel`,
|
||||||
|
`ErrorMessageNotFounded`, `ErrorPostNotFounded`, `ErrorWebAppNotExist`,
|
||||||
|
`ContentLevelError`, `ItsYou` (ссылка на себя), `ShowContactRemoved`.
|
||||||
|
|
||||||
|
Отдельный флаг: `?externalCallback=1` в любой ссылке — результат прокидывается обратно
|
||||||
|
как внешний колбэк (`b0.d()`), плюс есть маршрут `:external_callback`.
|
||||||
|
Параметры `mt_*` (myTracker) вычищаются из ссылки до разбора.
|
||||||
|
|
||||||
|
## Внутренние маршруты (`:route`)
|
||||||
|
|
||||||
|
Полноценная часть диплинк-системы: 137 маршрутов, объявленных как
|
||||||
|
`DeepLinkRoute(uri, constraints, requiredParams, supportRoot)`. Матчинг — по пути без
|
||||||
|
ведущего `/`, регистронезависимо. Обязательные параметры передаются как query:
|
||||||
|
`max.ru/:profile?id=123&type=CHAT`. Если хотя бы одного обязательного параметра нет —
|
||||||
|
`Error`, экран не откроется.
|
||||||
|
|
||||||
|
Помимо http(s)/`max://` эти же маршруты дергаются изнутри приложения (`pz4.b/d`) и из пушей.
|
||||||
|
|
||||||
|
Пометки в таблице:
|
||||||
|
- **только внутри приложения** — маршрут исключён из обработки внешних ссылок (`constraints` содержит `k2b.g`);
|
||||||
|
- **без авторизации** — доступен без активной сессии, иначе редирект на `:login`;
|
||||||
|
- **не может быть корневым экраном** — `supportRoot = false`.
|
||||||
|
|
||||||
|
| Маршрут | Обязательные параметры | Примечания |
|
||||||
|
|---|---|---|
|
||||||
|
| `:app-update/force` | — | без авторизации |
|
||||||
|
| `:attach/viewer` | `chat_id`, `attach_id`, `msg_id` | |
|
||||||
|
| `:auth` | — | |
|
||||||
|
| `:call-active` | — | |
|
||||||
|
| `:call-admin-settings` | — | |
|
||||||
|
| `:call-admin-waiting-room` | — | |
|
||||||
|
| `:call-chat` | `chat_id` | только внутри приложения |
|
||||||
|
| `:call-contact` | — | |
|
||||||
|
| `:call-debug-menu` | — | |
|
||||||
|
| `:call-history-info` | — | |
|
||||||
|
| `:call-incoming` | `chat_id`, `call_name` | |
|
||||||
|
| `:call-join-link` | `link` | только внутри приложения |
|
||||||
|
| `:call-join-preview` | `link` | |
|
||||||
|
| `:call-list` | — | |
|
||||||
|
| `:call-opponents-list` | — | |
|
||||||
|
| `:call-pip` | — | |
|
||||||
|
| `:call-presettings` | `chat_id` | |
|
||||||
|
| `:call-rate` | `call_id`, `is_group`, `is_video` | |
|
||||||
|
| `:call-user` | `opponent_id` | только внутри приложения |
|
||||||
|
| `:calls-history` | — | |
|
||||||
|
| `:chat-list` | — | |
|
||||||
|
| `:chat/add-icon` | — | |
|
||||||
|
| `:chats` | `id`, `type` | |
|
||||||
|
| `:chats-search` | — | |
|
||||||
|
| `:chats/callshare` | — | |
|
||||||
|
| `:chats/forward` | `messages_ids` | |
|
||||||
|
| `:chats/share` | — | |
|
||||||
|
| `:comments` | `parent_chat_server_id`, `parent_message_server_id` | |
|
||||||
|
| `:complaint` | — | |
|
||||||
|
| `:contact-list` | — | |
|
||||||
|
| `:contact-list/create-contact` | — | |
|
||||||
|
| `:contact-list/share-invite` | — | |
|
||||||
|
| `:contact/add/dialog` | `contact_id` | |
|
||||||
|
| `:contacts-picker` | `request_code` | |
|
||||||
|
| `:dialogs/file-download-warning` | `chat_id`, `message_id`, `file_id`, `file_name`, `file_size` | |
|
||||||
|
| `:dialogs/share-media` | `msg_id`, `attach_id`, `local_attach_id`, `cause_ordinal` | |
|
||||||
|
| `:external_callback` | — | |
|
||||||
|
| `:inAppReview/fake` | — | |
|
||||||
|
| `:invite/friends_to_max_bottom_sheet` | — | |
|
||||||
|
| `:invite/phone` | — | |
|
||||||
|
| `:invite/qr` | — | |
|
||||||
|
| `:join` | `id`, `link` | |
|
||||||
|
| `:link-intercept` | — | |
|
||||||
|
| `:location/pick` | `chat_id`, `request_code` | |
|
||||||
|
| `:location/show` | `chat_id`, `lat`, `lon`, `z` | |
|
||||||
|
| `:login` | — | без авторизации |
|
||||||
|
| `:logout` | — | без авторизации |
|
||||||
|
| `:media-editor` | — | |
|
||||||
|
| `:media-editor/crop` | `image_uri`, `file_path`, `mode` | |
|
||||||
|
| `:media-picker/select/photo` | — | без авторизации, не корневой экран |
|
||||||
|
| `:neuro-avatars` | `id` | |
|
||||||
|
| `:photo-editor` | — | |
|
||||||
|
| `:polls/create` | `chat_id`, `request_code` | |
|
||||||
|
| `:polls/result` | `chat_id`, `message_id`, `poll_id` | |
|
||||||
|
| `:polls/result/voters` | `chat_id`, `message_id`, `poll_id`, `answer_id` | |
|
||||||
|
| `:profile` | `id`, `type` | |
|
||||||
|
| `:profile/add-admins` | `chat_id` | |
|
||||||
|
| `:profile/add-members` | `chat_id`, `is_chat` | |
|
||||||
|
| `:profile/attaches` | `id` | |
|
||||||
|
| `:profile/avatars` | `id`, `type` | |
|
||||||
|
| `:profile/change-owner` | `chat_id` | |
|
||||||
|
| `:profile/comments-black-list` | `id` | |
|
||||||
|
| `:profile/edit` | `id`, `type` | |
|
||||||
|
| `:profile/edit/admin_permission` | `chat_id`, `contact_id`, `permissions_type` | |
|
||||||
|
| `:profile/edit/link` | `id`, `type`, `flow` | |
|
||||||
|
| `:profile/edit/reactions` | `id` | |
|
||||||
|
| `:profile/invite` | `id` | |
|
||||||
|
| `:profile/join-requests` | `id` | |
|
||||||
|
| `:profile/member_permissions` | `id` | |
|
||||||
|
| `:profile/members` | `id`, `type` | |
|
||||||
|
| `:qr-scanner` | — | |
|
||||||
|
| `:saved-messages` | — | |
|
||||||
|
| `:scheduled-messages` | `id` | |
|
||||||
|
| `:settings` | — | |
|
||||||
|
| `:settings/aboutapp` | — | |
|
||||||
|
| `:settings/appearance` | — | |
|
||||||
|
| `:settings/battery` | — | |
|
||||||
|
| `:settings/blacklist` | — | |
|
||||||
|
| `:settings/caching` | — | |
|
||||||
|
| `:settings/dev` | — | без авторизации, не корневой экран |
|
||||||
|
| `:settings/dev/integritylogsviewer` | — | без авторизации, не корневой экран |
|
||||||
|
| `:settings/dev/logsviewer` | — | без авторизации, не корневой экран |
|
||||||
|
| `:settings/dev/memorydebugger` | — | без авторизации |
|
||||||
|
| `:settings/dev/showroom` | — | без авторизации |
|
||||||
|
| `:settings/dev/threadsviewer` | — | без авторизации |
|
||||||
|
| `:settings/devices` | — | |
|
||||||
|
| `:settings/folder` | `id` | |
|
||||||
|
| `:settings/folder-list` | — | |
|
||||||
|
| `:settings/folder/by-chat` | `ids` | |
|
||||||
|
| `:settings/folder/create` | — | |
|
||||||
|
| `:settings/folder/edit` | — | |
|
||||||
|
| `:settings/folder/members-picker` | — | |
|
||||||
|
| `:settings/folder/settings` | — | |
|
||||||
|
| `:settings/locale` | — | |
|
||||||
|
| `:settings/magic-room` | — | без авторизации |
|
||||||
|
| `:settings/media` | — | |
|
||||||
|
| `:settings/media/autoload/video` | — | |
|
||||||
|
| `:settings/media/autosave` | `type` | |
|
||||||
|
| `:settings/messages` | — | |
|
||||||
|
| `:settings/notifications` | — | |
|
||||||
|
| `:settings/notifications/chat` | — | |
|
||||||
|
| `:settings/notifications/dialog` | — | |
|
||||||
|
| `:settings/notifications/other` | — | |
|
||||||
|
| `:settings/privacy` | — | |
|
||||||
|
| `:settings/privacy/creation-twofa` | `track_id`, `src` | |
|
||||||
|
| `:settings/privacy/onboarding` | — | |
|
||||||
|
| `:settings/privacy/onboarding-twofa` | `state` | |
|
||||||
|
| `:settings/privacy/pincode` | `mode` | |
|
||||||
|
| `:settings/privacy/profile-deletion` | — | |
|
||||||
|
| `:settings/ringtone` | — | |
|
||||||
|
| `:settings/server-host` | — | без авторизации |
|
||||||
|
| `:settings/server-port` | — | без авторизации |
|
||||||
|
| `:settings/webapp` | `bot_id` | |
|
||||||
|
| `:settings/webapps` | — | |
|
||||||
|
| `:share` | `text` | |
|
||||||
|
| `:start-conversation` | — | |
|
||||||
|
| `:start-conversation/add-subscribers` | `id` | |
|
||||||
|
| `:start-conversation/channel` | — | |
|
||||||
|
| `:start-conversation/chat` | — | |
|
||||||
|
| `:stickers/favorite` | — | |
|
||||||
|
| `:stickers/preview` | `sticker_id` | |
|
||||||
|
| `:stickers/recent` | — | |
|
||||||
|
| `:stickers/search` | — | |
|
||||||
|
| `:stickers/set` | `set_id` | |
|
||||||
|
| `:stickers/settings` | — | |
|
||||||
|
| `:stickers/showcase` | — | |
|
||||||
|
| `:stories/edit-privacy` | `story_id`, `settings` | |
|
||||||
|
| `:stories/publish` | `path` | |
|
||||||
|
| `:stories/publish/picker` | `title` | |
|
||||||
|
| `:stories/viewer` | `owner_id`, `owner_type`, `type` | |
|
||||||
|
| `:story/editor` | — | |
|
||||||
|
| `:twofa/auth/password/check` | `track_id`, `phone` | без авторизации, не корневой экран |
|
||||||
|
| `:twofa/password/check` | — | |
|
||||||
|
| `:unknown-call` | `call_id`, `caller_id` | |
|
||||||
|
| `:videoweb/full` | `chat_id`, `msg_id` | |
|
||||||
|
| `:webapp:root` | `bot_id`, `entry_point` | |
|
||||||
|
| `:webview/faq` | — | без авторизации, не корневой экран |
|
||||||
|
|
||||||
|
## Как это работает в Komet
|
||||||
|
|
||||||
|
Точка входа одна для всех случаев: `MaxLink.parse()` в `lib/core/links/max_link.dart`
|
||||||
|
разбирает ссылку в sealed-тип, `tryHandleMaxLink()` в
|
||||||
|
`lib/frontend/widgets/max_link_handler.dart` его исполняет. Через неё идут и внешние
|
||||||
|
диплинки (`DeepLinkService` → `app_links`), и тапы внутри приложения
|
||||||
|
(`openExternalUrl` → текст сообщений, био, описания каналов, inline-кнопки, упоминания).
|
||||||
|
Ссылки без схемы (`max.ru/…`) распознаются и парсером, и автолинковкой в тексте.
|
||||||
|
Ссылки, которым не нужен сервер (маршруты, `:share`, папки, вкладки), больше не ждут
|
||||||
|
подключения — `DeepLinkService` проверяет `MaxLink.needsConnection`.
|
||||||
|
|
||||||
|
Манифест Komet уже ловит `https/http max.ru` (+ `www.`) и схемы `komet://`, `max://`.
|
||||||
|
|
||||||
|
### Контентные ссылки
|
||||||
|
|
||||||
|
| Ссылка | Поведение в Komet |
|
||||||
|
|---|---|
|
||||||
|
| `max.ru` | возврат на корневой экран |
|
||||||
|
| `max.ru/<username>`, `@<nickname>`, `u/<id>` | резолв через `LINK_INFO` → чат, канал или профиль |
|
||||||
|
| `?start=<payload>` | диалог с ботом + автоотправка `botStarted` |
|
||||||
|
| `?startapp=<payload>` | мини-приложение бота (`WebAppScreen`), botId берётся из резолва ссылки |
|
||||||
|
| `join/<code>` | подтверждение вступления и переход в чат |
|
||||||
|
| `joincall/<code>` | экран входа в звонок |
|
||||||
|
| `stickerset/<id>` | шит стикерпака |
|
||||||
|
| `<username>/<messageId>`, `c/<chatId>/<messageId>` | чат с переходом к сообщению (время берётся из `LINK_INFO`, иначе догружаем историю назад) |
|
||||||
|
| `?uid=<userId>` | профиль контакта |
|
||||||
|
| `?cid=<chatId>` | чат по серверному id |
|
||||||
|
| `:folder?id=` | корневой список с выбранной папкой |
|
||||||
|
| `:auth/<token>` | подтверждение веб-логина (в отличие от MAX токен не отбрасывается — он нужен нашему флоу) |
|
||||||
|
| `:current` | ничего не делает, как в MAX |
|
||||||
|
| `:share?text=` | панель пересылки → выбранный чат с текстом в поле ввода |
|
||||||
|
| `:share-self-out` | системный шэринг своей публичной ссылки |
|
||||||
|
| чужой хост | внешний браузер |
|
||||||
|
|
||||||
|
### Внутренние маршруты
|
||||||
|
|
||||||
|
Реализованы в `lib/frontend/widgets/max_route_handler.dart`:
|
||||||
|
|
||||||
|
| Маршрут | Экран Komet |
|
||||||
|
|---|---|
|
||||||
|
| `:chat-list`, `:settings/folder-list` | список чатов |
|
||||||
|
| `:calls-history`, `:call-list` | вкладка звонков |
|
||||||
|
| `:contact-list` | вкладка контактов |
|
||||||
|
| `:settings` | вкладка настроек |
|
||||||
|
| `:chats-search` | поиск |
|
||||||
|
| `:saved-messages` | Избранное |
|
||||||
|
| `:chats?id=` | чат |
|
||||||
|
| `:profile?id=&type=` | профиль контакта или чата (по `type`) |
|
||||||
|
| `:profile/members`, `:profile/avatars` | тот же профиль |
|
||||||
|
| `:profile/attaches?id=` | профиль на вкладке медиа |
|
||||||
|
| `:profile/edit` | редактирование своего профиля |
|
||||||
|
| `:scheduled-messages?id=` | отложенные сообщения чата |
|
||||||
|
| `:stickers/set?set_id=` | шит стикерпака |
|
||||||
|
| `:webapp:root?bot_id=&entry_point=`, `:settings/webapp?bot_id=` | мини-приложение |
|
||||||
|
| `:location/show?lat=&lon=&z=` | карта |
|
||||||
|
| `:qr-scanner` | сканер QR |
|
||||||
|
| `:settings/appearance` | внешний вид |
|
||||||
|
| `:settings/notifications` (+ `/chat`, `/dialog`, `/other`) | уведомления |
|
||||||
|
| `:settings/devices` | устройства |
|
||||||
|
| `:settings/aboutapp` | о приложении |
|
||||||
|
| `:settings/privacy`, `:settings/privacy/pincode`, `:settings/blacklist` | безопасность |
|
||||||
|
| `:settings/messages` | действия с сообщениями |
|
||||||
|
| `:settings/dev` (+ подэкраны) | отладочное меню |
|
||||||
|
| `:link-intercept`, `:external_callback` | no-op |
|
||||||
|
|
||||||
|
Остальные маршруты из таблицы выше показывают уведомление
|
||||||
|
«Ссылка не поддерживается: `<маршрут>`» — в Komet для них нет экрана. Сознательно не
|
||||||
|
подключены: `:login`/`:logout` (деструктивно), `:polls/*`, `:comments`, `:chats/forward`,
|
||||||
|
`:stories/*`, `:call-*` (кроме списка), `:twofa/*`, `:complaint`, `:media-*`,
|
||||||
|
`:attach/viewer`, `:videoweb/full`, `:invite/*` — им нужны либо чужие экраны, либо
|
||||||
|
контекст, которого в ссылке нет. Флаг `?externalCallback=1` не обрабатывается.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:komet/backend/modules/messages.dart';
|
||||||
|
import 'package:komet/frontend/widgets/message_bubble.dart';
|
||||||
|
import 'package:komet/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
const int _me = 1;
|
||||||
|
|
||||||
|
CachedMessage _botStart({String? payload}) => CachedMessage.fromPushPayload(
|
||||||
|
_me,
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
'id': '5005',
|
||||||
|
'time': DateTime(2026, 1, 1, 18, 6).millisecondsSinceEpoch,
|
||||||
|
'type': 'USER',
|
||||||
|
'sender': _me,
|
||||||
|
'text': payload ?? '',
|
||||||
|
'attaches': [
|
||||||
|
{'_type': 'CONTROL', 'event': 'botStarted'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _pump(WidgetTester tester, CachedMessage message) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('ru'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: MessageBubble(
|
||||||
|
message: message,
|
||||||
|
isMe: true,
|
||||||
|
myId: _me,
|
||||||
|
chatType: 'DIALOG',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('a start with a payload shows a service line', (tester) async {
|
||||||
|
await _pump(tester, _botStart(payload: 'abc123'));
|
||||||
|
|
||||||
|
expect(find.text('Бот запущен: abc123'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a start without a payload takes no space', (tester) async {
|
||||||
|
await _pump(tester, _botStart());
|
||||||
|
|
||||||
|
expect(find.textContaining('Бот запущен'), findsNothing);
|
||||||
|
expect(tester.getSize(find.byType(MessageBubble)), Size.zero);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ void main() {
|
|||||||
CachedMessage parse(Map<String, dynamic> message) =>
|
CachedMessage parse(Map<String, dynamic> message) =>
|
||||||
CachedMessage.fromPushPayload(1001, 2002, message);
|
CachedMessage.fromPushPayload(1001, 2002, message);
|
||||||
|
|
||||||
test('is a control message and a start marker', () {
|
test('is control and shows the payload the server put into text', () {
|
||||||
final message = parse({
|
final message = parse({
|
||||||
'id': '3003',
|
'id': '3003',
|
||||||
'time': 1700000000000,
|
'time': 1700000000000,
|
||||||
@@ -21,10 +21,47 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(message.isControl, isTrue);
|
expect(message.isControl, isTrue);
|
||||||
expect(message.isBotStartMarker, isTrue);
|
expect(message.botStartPayload, 'abc123');
|
||||||
|
expect(message.isSilentBotStart, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('other control events are not start markers', () {
|
test('reads the payload off the attach when it is there', () {
|
||||||
|
final message = parse({
|
||||||
|
'id': '3006',
|
||||||
|
'time': 1700000000000,
|
||||||
|
'type': 'USER',
|
||||||
|
'sender': 1001,
|
||||||
|
'attaches': [
|
||||||
|
{
|
||||||
|
'_type': 'CONTROL',
|
||||||
|
'event': 'botStarted',
|
||||||
|
'startPayload': 'abc123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message.botStartPayload, 'abc123');
|
||||||
|
expect(message.isSilentBotStart, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a start without a payload stays hidden', () {
|
||||||
|
final message = parse({
|
||||||
|
'id': '3007',
|
||||||
|
'time': 1700000000000,
|
||||||
|
'type': 'USER',
|
||||||
|
'sender': 1001,
|
||||||
|
'text': '',
|
||||||
|
'attaches': [
|
||||||
|
{'_type': 'CONTROL', 'event': 'botStarted'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message.isControl, isTrue);
|
||||||
|
expect(message.botStartPayload, isNull);
|
||||||
|
expect(message.isSilentBotStart, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other control events are untouched', () {
|
||||||
final message = parse({
|
final message = parse({
|
||||||
'id': '3004',
|
'id': '3004',
|
||||||
'time': 1700000000000,
|
'time': 1700000000000,
|
||||||
@@ -36,10 +73,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(message.isControl, isTrue);
|
expect(message.isControl, isTrue);
|
||||||
expect(message.isBotStartMarker, isFalse);
|
expect(message.botStartPayload, isNull);
|
||||||
|
expect(message.isSilentBotStart, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a plain message is neither', () {
|
test('a plain message is not a start at all', () {
|
||||||
final message = parse({
|
final message = parse({
|
||||||
'id': '3005',
|
'id': '3005',
|
||||||
'time': 1700000000000,
|
'time': 1700000000000,
|
||||||
@@ -50,7 +88,8 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(message.isControl, isFalse);
|
expect(message.isControl, isFalse);
|
||||||
expect(message.isBotStartMarker, isFalse);
|
expect(message.botStartPayload, isNull);
|
||||||
|
expect(message.isSilentBotStart, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the event name used on the wire stays stable', () {
|
test('the event name used on the wire stays stable', () {
|
||||||
|
|||||||
+124
-32
@@ -35,58 +35,150 @@ void main() {
|
|||||||
expect(linkTarget('http://max.ru/somebot'), 'http://max.ru/somebot');
|
expect(linkTarget('http://max.ru/somebot'), 'http://max.ru/somebot');
|
||||||
expect(linkTarget('https://max.ru/somebot'), 'https://max.ru/somebot');
|
expect(linkTarget('https://max.ru/somebot'), 'https://max.ru/somebot');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('a bare link is parsed as a max link once normalized', () {
|
group('MaxLink.parse — не наши ссылки', () {
|
||||||
final link = MaxLink.parse(linkTarget('max.ru/somebot?start=abc123'));
|
test('rejects other hosts and non-links', () {
|
||||||
|
expect(MaxLink.parse('https://example.com/somebot'), isNull);
|
||||||
|
expect(MaxLink.parse('evil.max.ru/phish'), isNull);
|
||||||
|
expect(MaxLink.parse('mailto:bot@max.ru'), isNull);
|
||||||
|
expect(MaxLink.parse(''), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
expect(link!.kind, MaxLinkKind.public);
|
test('rejects reserved site pages', () {
|
||||||
expect(link.startPayload, 'abc123');
|
expect(MaxLink.parse('https://max.ru/login'), isNull);
|
||||||
|
expect(MaxLink.parse('https://max.ru/tos'), isNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('MaxLink start payload', () {
|
group('MaxLink.parse — контентные ссылки', () {
|
||||||
test('parses a bot start link over http', () {
|
test('bare host, http and www all normalize to the root link', () {
|
||||||
final link = MaxLink.parse('http://max.ru/id100000000001bot?start=abc123');
|
expect(MaxLink.parse('max.ru'), isA<MaxRootLink>());
|
||||||
|
expect(MaxLink.parse('http://max.ru/'), isA<MaxRootLink>());
|
||||||
expect(link, isNotNull);
|
expect(MaxLink.parse('https://www.max.ru'), isA<MaxRootLink>());
|
||||||
expect(link!.kind, MaxLinkKind.public);
|
expect(MaxLink.parse('max://max.ru/'), isA<MaxRootLink>());
|
||||||
expect(link.startPayload, 'abc123');
|
|
||||||
expect(link.baseUrl, 'https://max.ru/id100000000001bot');
|
|
||||||
expect(link.url, 'http://max.ru/id100000000001bot?start=abc123');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decodes the payload and ignores a trailing fragment', () {
|
test('public link keeps the canonical https url', () {
|
||||||
final link = MaxLink.parse(
|
final link = MaxLink.parse('max.ru/somebot') as MaxContentLink;
|
||||||
'https://www.max.ru/somebot?start=a%20b&ref=x#top',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(link!.startPayload, 'a b');
|
expect(link.kind, MaxContentKind.public);
|
||||||
|
expect(link.url, 'https://max.ru/somebot');
|
||||||
|
expect(link.baseUrl, 'https://max.ru/somebot');
|
||||||
|
expect(link.startPayload, isNull);
|
||||||
|
expect(link.messageId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@nickname is a public link too', () {
|
||||||
|
final link = MaxLink.parse('https://max.ru/@somebot') as MaxContentLink;
|
||||||
|
|
||||||
|
expect(link.kind, MaxContentKind.public);
|
||||||
expect(link.baseUrl, 'https://max.ru/somebot');
|
expect(link.baseUrl, 'https://max.ru/somebot');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps the payload empty for links without one', () {
|
test('bot start payload is parsed and the base url drops it', () {
|
||||||
expect(MaxLink.parse('https://max.ru/somebot')!.startPayload, isNull);
|
final link =
|
||||||
|
MaxLink.parse('http://max.ru/id100000000001_bot?start=a%20b')
|
||||||
|
as MaxContentLink;
|
||||||
|
|
||||||
|
expect(link.startPayload, 'a b');
|
||||||
|
expect(link.url, 'https://max.ru/id100000000001_bot?start=a%20b');
|
||||||
|
expect(link.baseUrl, 'https://max.ru/id100000000001_bot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty start payload is ignored', () {
|
||||||
|
final link = MaxLink.parse('max.ru/somebot?start=') as MaxContentLink;
|
||||||
|
expect(link.startPayload, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startapp opens a mini app and is cut at the first &', () {
|
||||||
|
final link =
|
||||||
|
MaxLink.parse('max.ru/somebot?startapp=deal%2F42&ref=x')
|
||||||
|
as MaxWebAppLink;
|
||||||
|
|
||||||
|
expect(link.startApp, 'deal/42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message links carry the message id', () {
|
||||||
|
final byName =
|
||||||
|
MaxLink.parse('max.ru/somechannel/117008613873053494')
|
||||||
|
as MaxContentLink;
|
||||||
|
expect(byName.kind, MaxContentKind.public);
|
||||||
|
expect(byName.messageId, 117008613873053494);
|
||||||
|
expect(byName.baseUrl, 'https://max.ru/somechannel');
|
||||||
|
|
||||||
|
final byId =
|
||||||
|
MaxLink.parse('max.ru/c/1673760/117008613873053494')
|
||||||
|
as MaxContentLink;
|
||||||
|
expect(byId.kind, MaxContentKind.content);
|
||||||
|
expect(byId.messageId, 117008613873053494);
|
||||||
|
expect(byId.baseUrl, 'https://max.ru/c/1673760');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invite, call and sticker links keep their own types', () {
|
||||||
expect(
|
expect(
|
||||||
MaxLink.parse('https://max.ru/somebot?start=')!.startPayload,
|
(MaxLink.parse('max.ru/join/AbCdEf') as MaxContentLink).kind,
|
||||||
isNull,
|
MaxContentKind.invite,
|
||||||
);
|
);
|
||||||
|
expect(MaxLink.parse('max.ru/joincall/AbCdEf'), isA<MaxCallLink>());
|
||||||
expect(
|
expect(
|
||||||
MaxLink.parse('https://max.ru/somebot?other=1')!.startPayload,
|
(MaxLink.parse('max.ru/stickerset/512-abc') as MaxStickerSetLink).path,
|
||||||
isNull,
|
'stickerset/512-abc',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('leaves other link kinds untouched', () {
|
test('uid and cid open a contact and a chat', () {
|
||||||
final invite = MaxLink.parse('https://max.ru/join/AbCdEf?start=x');
|
expect(
|
||||||
|
(MaxLink.parse('max://max.ru/?uid=105587131') as MaxContactIdLink)
|
||||||
|
.userId,
|
||||||
|
105587131,
|
||||||
|
);
|
||||||
|
final chat = MaxLink.parse('max://max.ru/?cid=1673760') as MaxChatIdLink;
|
||||||
|
expect(chat.chatId, 1673760);
|
||||||
|
expect(chat.messageId, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(invite!.kind, MaxLinkKind.invite);
|
group('MaxLink.parse — внутренние маршруты', () {
|
||||||
expect(invite.startPayload, isNull);
|
test('auth keeps the full url with its token', () {
|
||||||
expect(invite.baseUrl, invite.url);
|
final link = MaxLink.parse('https://max.ru/:auth/tok3n') as MaxAuthLink;
|
||||||
|
expect(link.url, 'https://max.ru/:auth/tok3n');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('still rejects non-max links', () {
|
test('share and share-self-out are separate targets', () {
|
||||||
expect(MaxLink.parse('https://example.com/somebot?start=x'), isNull);
|
final share =
|
||||||
expect(MaxLink.parse('https://max.ru/?start=x'), isNull);
|
MaxLink.parse('https://max.ru/:share?text=%D0%BF%D1%80%D0%B8%D0%B2')
|
||||||
|
as MaxShareTextLink;
|
||||||
|
expect(share.text, 'прив');
|
||||||
|
expect(
|
||||||
|
MaxLink.parse('https://max.ru/:share-self-out'),
|
||||||
|
isA<MaxShareSelfLink>(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('folder needs an id, otherwise it stays a plain route', () {
|
||||||
|
expect(
|
||||||
|
(MaxLink.parse('max.ru/:folder?id=42') as MaxFolderLink).folderId,
|
||||||
|
'42',
|
||||||
|
);
|
||||||
|
expect(MaxLink.parse('max.ru/:folder'), isA<MaxRouteLink>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current is a no-op target', () {
|
||||||
|
expect(MaxLink.parse('max.ru/:current'), isA<MaxCurrentLink>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other routes keep their path and query params', () {
|
||||||
|
final route =
|
||||||
|
MaxLink.parse('max://max.ru/:profile?id=123&type=CHAT')
|
||||||
|
as MaxRouteLink;
|
||||||
|
|
||||||
|
expect(route.route, ':profile');
|
||||||
|
expect(route.params, {'id': '123', 'type': 'CHAT'});
|
||||||
|
|
||||||
|
final nested =
|
||||||
|
MaxLink.parse('https://max.ru/:Settings/Appearance') as MaxRouteLink;
|
||||||
|
expect(nested.route, ':settings/appearance');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user