From 63b1709fbfd6dcb497a037499028d5d251ed5d0d Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Wed, 14 Jan 2026 11:23:28 -0800 Subject: [PATCH 1/2] feat: improve link handling for lemmy/piefed links --- lib/src/shared/utils/instance.dart | 177 ++++----------- lib/src/shared/utils/link_utils.dart | 318 +++++++++++++++++++++++++++ test/utils/link_utils_test.dart | 229 +++++++++++++++++++ 3 files changed, 595 insertions(+), 129 deletions(-) create mode 100644 lib/src/shared/utils/link_utils.dart create mode 100644 test/utils/link_utils_test.dart diff --git a/lib/src/shared/utils/instance.dart b/lib/src/shared/utils/instance.dart index 24cd4fc60..647273a2f 100644 --- a/lib/src/shared/utils/instance.dart +++ b/lib/src/shared/utils/instance.dart @@ -11,6 +11,7 @@ import 'package:thunder/src/core/models/models.dart'; import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/shared/pages/loading_page.dart'; +import 'package:thunder/src/shared/utils/link_utils.dart'; String? fetchInstanceNameFromUrl(String? url) { if (url == null) { @@ -21,160 +22,78 @@ String? fetchInstanceNameFromUrl(String? url) { return uri.host; } -/// Matches instance.tld/c/community@otherinstance.tld -/// Puts community in group 3 and otherinstance.tld in group 4 -/// https://regex101.com/r/sE8SmL/1 -final RegExp fullCommunityUrl = RegExp(r'^!?(https?:\/\/)?(.*)\/c\/(.*)@(.*)$'); - -/// Matches instance.tld/c/community -/// Puts community in group 3 and instance.tld in group 2 -/// https://regex101.com/r/AW2qTr/1 -final RegExp shortCommunityUrl = RegExp(r'^!?(https?:\/\/)?(.*)\/c\/([^@\n]*)$'); - -/// Matches community@instance.tld -/// Puts community in group 2 and instance.tld in group 3 -/// https://regex101.com/r/1VrXgX/1 -final RegExp instanceName = RegExp(r'^!?(https?:\/\/)?((?:(?!\/c\/c).)*)@(.*)$'); - -/// Checks if the given text references a community on a valid Lemmy server. +/// Checks if the given text references a community on a valid Lemmy/PieFed server. /// If so, returns the community name in the format community@instance.tld. /// Otherwise, returns null. Future getLemmyCommunity(String text) async { - // Do an initial check for usernames in the format /u/user@instance.tld or @user@instance.tld. - // These can accidentally trip our community name detection. - if (text.toLowerCase().startsWith('/u/') || text.toLowerCase().startsWith('@')) { - return null; - } - - final RegExpMatch? fullCommunityUrlMatch = fullCommunityUrl.firstMatch(text); - if (fullCommunityUrlMatch != null && fullCommunityUrlMatch.groupCount >= 4) { - return '${fullCommunityUrlMatch.group(3)}@${fullCommunityUrlMatch.group(4)}'; - } - - final RegExpMatch? shortCommunityUrlMatch = shortCommunityUrl.firstMatch(text); - if (shortCommunityUrlMatch != null && shortCommunityUrlMatch.groupCount >= 3) { - return '${shortCommunityUrlMatch.group(3)}@${shortCommunityUrlMatch.group(2)}'; - } - - final RegExpMatch? instanceNameMatch = instanceName.firstMatch(text); - if (instanceNameMatch != null && instanceNameMatch.groupCount >= 3) { - return '${instanceNameMatch.group(2)}@${instanceNameMatch.group(3)}'; - } - - return null; + final result = parseCommunity(text); + return result?.qualified; } -/// Matches instance.tld/u/username@otherinstance.tld -/// Puts username in group 3 and otherinstance.tld in group 4 -final RegExp fullUsernameUrl = RegExp(r'^@?(https?:\/\/)?(.*)\/u\/(.*)@(.*)$'); - -/// Matches instance.tld/u/username -/// Puts username in group 3 and instance.tld in group 2 -final RegExp shortUsernameUrl = RegExp(r'^@?(https?:\/\/)?(.*)\/u\/([^@\n]*)$'); - -/// Matches username@instance.tld -/// Puts username in group 2 and instance.tld in group 3 -final RegExp username = RegExp(r'^@?(https?:\/\/)?((?:(?!\/u\/u).)*)@(.*)$'); - -/// Checks if the given text references a user on a valid Lemmy server. -/// If so, returns the username name in the format username@instance.tld. +/// Checks if the given text references a user on a valid Lemmy/PieFed server. +/// If so, returns the username in the format username@instance.tld. /// Otherwise, returns null. Future getLemmyUser(String text) async { - // Do an initial check for communities in the format /c/community@instance.tld or !community@instance.tld. - // These can accidentally trip our user name detection. - if (text.toLowerCase().startsWith('/c/') || text.toLowerCase().startsWith('!')) { - return null; - } + final result = parseUser(text); + return result?.qualified; +} - final RegExpMatch? fullUsernameUrlMatch = fullUsernameUrl.firstMatch(text); - if (fullUsernameUrlMatch != null && fullUsernameUrlMatch.groupCount >= 4) { - return '${fullUsernameUrlMatch.group(3)}@${fullUsernameUrlMatch.group(4)}'; +/// Gets the post ID from a Lemmy/PieFed URL. +/// If the URL is from a different instance, it will attempt to resolve it. +Future getLemmyPostId(BuildContext context, String text) async { + final parsed = parsePostId(text); + if (parsed == null) { + return null; } - final RegExpMatch? shortUsernameUrlMatch = shortUsernameUrl.firstMatch(text); - if (shortUsernameUrlMatch != null && shortUsernameUrlMatch.groupCount >= 3) { - return '${shortUsernameUrlMatch.group(3)}@${shortUsernameUrlMatch.group(2)}'; - } + final account = context.read().state.account; + final postId = int.tryParse(parsed.value); - final RegExpMatch? usernameMatch = username.firstMatch(text); - if (usernameMatch != null && usernameMatch.groupCount >= 3) { - return '${usernameMatch.group(2)}@${usernameMatch.group(3)}'; + if (postId == null) { + return null; } - return null; -} - -final RegExp _post = RegExp(r'^(https?:\/\/)(.*)\/post\/([0-9]*).*$'); -Future getLemmyPostId(BuildContext context, String text) async { - final account = context.read().state.account; - - final RegExpMatch? postMatch = _post.firstMatch(text); - if (postMatch != null) { - final String? instance = postMatch.group(2); - final int? postId = int.tryParse(postMatch.group(3)!); - if (postId != null) { - if (instance == account.instance) { - return postId; - } else { - // This is a post on another instance. Try to resolve it - try { - // Show the loading page while we resolve the post - showLoadingPage(context); - - final response = await SearchRepositoryImpl(account: account).resolve(query: text); - return response['post']?.id; - } catch (e) { - return null; - } - } + if (parsed.instance == account.instance) { + return postId; + } else { + // This is a post on another instance. Try to resolve it + try { + showLoadingPage(context); + final response = await SearchRepositoryImpl(account: account).resolve(query: text); + return response['post']?.id; + } catch (e) { + return null; } } - - return null; } -final RegExp _comment = RegExp(r'^(https?:\/\/)(.*)\/comment\/([0-9]*).*$'); -final RegExp _commentAlternate = RegExp(r'^(https?:\/\/)(.*)\/post\/([0-9]*)\/([0-9]*).*$'); +/// Gets the comment ID from a Lemmy/PieFed URL. +/// If the URL is from a different instance, it will attempt to resolve it. Future getLemmyCommentId(BuildContext context, String text) async { - String? instance; - int? commentId; + final parsed = parseCommentId(text); + if (parsed == null) { + return null; + } final account = context.read().state.account; + final commentId = int.tryParse(parsed.value); - // Try legacy comment link format - RegExpMatch? commentMatch = _comment.firstMatch(text); - if (commentMatch != null) { - // It's a match! - instance = commentMatch.group(2); - commentId = int.tryParse(commentMatch.group(3)!); - } else { - // Otherwise, try the new format - commentMatch = _commentAlternate.firstMatch(text); - if (commentMatch != null) { - // It's a match! - instance = commentMatch.group(2); - commentId = int.tryParse(commentMatch.group(4)!); - } + if (commentId == null) { + return null; } - if (commentId != null) { - if (instance == account.instance) { - return commentId; - } else { - // This is a comment on another instance. Try to resolve it - try { - // Show the loading page while we resolve the post - showLoadingPage(context); - - final response = await SearchRepositoryImpl(account: account).resolve(query: text); - return response['comment']?.id; - } catch (e) { - return null; - } + if (parsed.instance == account.instance) { + return commentId; + } else { + // This is a comment on another instance. Try to resolve it + try { + showLoadingPage(context); + final response = await SearchRepositoryImpl(account: account).resolve(query: text); + return response['comment']?.id; + } catch (e) { + return null; } } - - return null; } /// Fetches the instance info for a given URL. diff --git a/lib/src/shared/utils/link_utils.dart b/lib/src/shared/utils/link_utils.dart new file mode 100644 index 000000000..973fd1d4e --- /dev/null +++ b/lib/src/shared/utils/link_utils.dart @@ -0,0 +1,318 @@ +/// Pure link parsing utilities for Lemmy and PieFed URLs. +/// +/// These functions extract community names, usernames, post IDs, and comment IDs +/// from platform-specific URL formats without any Flutter dependencies. +library; + +/// Result of parsing a link, containing the extracted value and source instance. +class ParsedLink { + /// The extracted value (community name, username, or ID as string) + final String value; + + /// The source instance from the URL + final String instance; + + const ParsedLink({required this.value, required this.instance}); + + /// Returns the value in qualified format: value@instance + String get qualified => '$value@$instance'; + + @override + String toString() => 'ParsedLink(value: $value, instance: $instance)'; + + @override + bool operator ==(Object other) => identical(this, other) || other is ParsedLink && runtimeType == other.runtimeType && value == other.value && instance == other.instance; + + @override + int get hashCode => value.hashCode ^ instance.hashCode; +} + +// ============================================================================ +// Lemmy URL Patterns +// ============================================================================ + +/// Matches instance.tld/c/community@otherinstance.tld +/// Groups: 2=instance, 3=community, 4=federatedInstance +final RegExp _lemmyFullCommunityUrl = RegExp(r'^!?(https?:\/\/)?(.*)/c/(.*)@(.*)$'); + +/// Matches instance.tld/c/community +/// Groups: 2=instance, 3=community +final RegExp _lemmyShortCommunityUrl = RegExp(r'^!?(https?:\/\/)?(.*)/c/([^@\n]*)$'); + +/// Matches community@instance.tld (excludes URLs with /c/ or /u/) +/// Groups: 2=community, 3=instance +final RegExp _lemmyCommunityMention = RegExp(r'^!?(https?:\/\/)?((?:(?!\/c\/c).)*)@(.*)$'); + +/// Matches instance.tld/u/username@otherinstance.tld +/// Groups: 2=instance, 3=username, 4=federatedInstance +final RegExp _lemmyFullUserUrl = RegExp(r'^@?(https?:\/\/)?(.*)/u/(.*)@(.*)$'); + +/// Matches instance.tld/u/username +/// Groups: 2=instance, 3=username +final RegExp _lemmyShortUserUrl = RegExp(r'^@?(https?:\/\/)?(.*)/u/([^@\n]*)$'); + +/// Matches username@instance.tld (excludes URLs with /u/) +/// Groups: 2=username, 3=instance +final RegExp _lemmyUserMention = RegExp(r'^@?(https?:\/\/)?((?:(?!\/u\/u).)*)@(.*)$'); + +/// Matches instance.tld/post/123 +/// Groups: 2=instance, 3=postId +final RegExp _lemmyPostUrl = RegExp(r'^(https?:\/\/)(.*)/post/([0-9]+)$'); + +/// Matches instance.tld/comment/123 +/// Groups: 2=instance, 3=commentId +final RegExp _lemmyCommentUrl = RegExp(r'^(https?:\/\/)(.*)/comment/([0-9]+).*$'); + +/// Matches instance.tld/post/123/456 (Lemmy new format) +/// Groups: 2=instance, 3=postId, 4=commentId +final RegExp _lemmyPostCommentUrl = RegExp(r'^(https?:\/\/)(.*)/post/([0-9]+)/([0-9]+)$'); + +// ============================================================================ +// PieFed URL Patterns +// ============================================================================ + +/// Matches instance.tld/post/123/comment/456 (PieFed format) +/// Groups: 2=instance, 3=postId, 4=commentId +final RegExp _piefedCommentUrl = RegExp(r'^(https?:\/\/)(.*)/post/([0-9]+)/comment/([0-9]+).*$'); + +// ============================================================================ +// Lemmy Parsers +// ============================================================================ + +/// Parses a Lemmy community URL. +/// +/// Supports formats: +/// - https://instance.tld/c/community +/// - https://instance.tld/c/community@other.tld +/// - !community@instance.tld +/// - community@instance.tld +ParsedLink? parseLemmyCommunity(String text) { + // Skip if this looks like a user URL + if (text.contains('/u/')) { + return null; + } + + // Try full community URL: /c/community@federatedInstance + final fullMatch = _lemmyFullCommunityUrl.firstMatch(text); + if (fullMatch != null && fullMatch.groupCount >= 4) { + return ParsedLink( + value: fullMatch.group(3)!, + instance: fullMatch.group(4)!, + ); + } + + // Try short community URL: /c/community + final shortMatch = _lemmyShortCommunityUrl.firstMatch(text); + if (shortMatch != null && shortMatch.groupCount >= 3) { + return ParsedLink( + value: shortMatch.group(3)!, + instance: shortMatch.group(2)!, + ); + } + + // Try mention format: !community@instance or community@instance + // But skip if starts with @ (user mention) + if (text.toLowerCase().startsWith('@')) { + return null; + } + final mentionMatch = _lemmyCommunityMention.firstMatch(text); + if (mentionMatch != null && mentionMatch.groupCount >= 3) { + return ParsedLink( + value: mentionMatch.group(2)!, + instance: mentionMatch.group(3)!, + ); + } + + return null; +} + +/// Parses a Lemmy user URL. +/// +/// Supports formats: +/// - https://instance.tld/u/username +/// - https://instance.tld/u/username@other.tld +/// - @username@instance.tld +/// - username@instance.tld +ParsedLink? parseLemmyUser(String text) { + // Skip if this looks like a community URL + if (text.contains('/c/')) { + return null; + } + + // Try full user URL: /u/username@federatedInstance + final fullMatch = _lemmyFullUserUrl.firstMatch(text); + if (fullMatch != null && fullMatch.groupCount >= 4) { + return ParsedLink( + value: fullMatch.group(3)!, + instance: fullMatch.group(4)!, + ); + } + + // Try short user URL: /u/username + final shortMatch = _lemmyShortUserUrl.firstMatch(text); + if (shortMatch != null && shortMatch.groupCount >= 3) { + return ParsedLink( + value: shortMatch.group(3)!, + instance: shortMatch.group(2)!, + ); + } + + // Try mention format: @username@instance or username@instance + // But skip if starts with ! (community mention) + if (text.toLowerCase().startsWith('!')) { + return null; + } + final mentionMatch = _lemmyUserMention.firstMatch(text); + if (mentionMatch != null && mentionMatch.groupCount >= 3) { + return ParsedLink( + value: mentionMatch.group(2)!, + instance: mentionMatch.group(3)!, + ); + } + + return null; +} + +/// Parses a Lemmy post URL. +/// +/// Supports formats: +/// - https://instance.tld/post/123 +ParsedLink? parseLemmyPostId(String text) { + // Skip if this is a comment URL (PieFed format or Lemmy new format) + if (text.contains('/comment/') || _lemmyPostCommentUrl.hasMatch(text)) { + return null; + } + + final match = _lemmyPostUrl.firstMatch(text); + if (match != null && match.groupCount >= 3) { + return ParsedLink( + value: match.group(3)!, + instance: match.group(2)!, + ); + } + + return null; +} + +/// Parses a Lemmy comment URL. +/// +/// Supports formats: +/// - https://instance.tld/comment/123 +/// - https://instance.tld/post/123/456 +ParsedLink? parseLemmyCommentId(String text) { + // Try legacy comment URL: /comment/123 + final commentMatch = _lemmyCommentUrl.firstMatch(text); + if (commentMatch != null && commentMatch.groupCount >= 3) { + return ParsedLink( + value: commentMatch.group(3)!, + instance: commentMatch.group(2)!, + ); + } + + // Try new Lemmy format: /post/123/456 + final postCommentMatch = _lemmyPostCommentUrl.firstMatch(text); + if (postCommentMatch != null && postCommentMatch.groupCount >= 4) { + return ParsedLink( + value: postCommentMatch.group(4)!, + instance: postCommentMatch.group(2)!, + ); + } + + return null; +} + +// ============================================================================ +// PieFed Parsers +// ============================================================================ + +/// Parses a PieFed community URL. +/// PieFed uses the same format as Lemmy for communities. +ParsedLink? parsePiefedCommunity(String text) => parseLemmyCommunity(text); + +/// Parses a PieFed user URL. +/// PieFed uses the same format as Lemmy for users. +ParsedLink? parsePiefedUser(String text) => parseLemmyUser(text); + +/// Parses a PieFed post URL. +/// +/// Supports formats: +/// - https://instance.tld/post/123 +ParsedLink? parsePiefedPostId(String text) { + // Skip if this is a comment URL + if (text.contains('/comment/')) { + return null; + } + + final match = _lemmyPostUrl.firstMatch(text); + if (match != null && match.groupCount >= 3) { + return ParsedLink( + value: match.group(3)!, + instance: match.group(2)!, + ); + } + + return null; +} + +/// Parses a PieFed comment URL. +/// +/// Supports formats: +/// - https://instance.tld/post/123/comment/456 +ParsedLink? parsePiefedCommentId(String text) { + final match = _piefedCommentUrl.firstMatch(text); + if (match != null && match.groupCount >= 4) { + return ParsedLink( + value: match.group(4)!, + instance: match.group(2)!, + ); + } + + return null; +} + +// ============================================================================ +// Unified Parsers +// ============================================================================ + +/// Parses a community URL from any supported platform. +/// +/// Tries Lemmy and PieFed formats. +ParsedLink? parseCommunity(String text) { + // Both Lemmy and PieFed use the same community URL format + return parseLemmyCommunity(text); +} + +/// Parses a user URL from any supported platform. +/// +/// Tries Lemmy and PieFed formats. +ParsedLink? parseUser(String text) { + // Both Lemmy and PieFed use the same user URL format + return parseLemmyUser(text); +} + +/// Parses a post URL from any supported platform. +/// +/// Tries PieFed first (to exclude comment URLs), then Lemmy. +ParsedLink? parsePostId(String text) { + // Check for PieFed comment format first to exclude it + if (parsePiefedCommentId(text) != null) { + return null; + } + + // Try Lemmy format (handles both Lemmy and PieFed post URLs) + return parseLemmyPostId(text); +} + +/// Parses a comment URL from any supported platform. +/// +/// Tries PieFed format first, then Lemmy formats. +ParsedLink? parseCommentId(String text) { + // Try PieFed format first: /post/123/comment/456 + final piefedResult = parsePiefedCommentId(text); + if (piefedResult != null) { + return piefedResult; + } + + // Try Lemmy formats: /comment/123 or /post/123/456 + return parseLemmyCommentId(text); +} diff --git a/test/utils/link_utils_test.dart b/test/utils/link_utils_test.dart new file mode 100644 index 000000000..0c6934197 --- /dev/null +++ b/test/utils/link_utils_test.dart @@ -0,0 +1,229 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunder/src/shared/utils/link_utils.dart'; + +void main() { + group('ParsedLink', () { + test('qualified returns value@instance', () { + const link = ParsedLink(value: 'news', instance: 'lemmy.world'); + expect(link.qualified, 'news@lemmy.world'); + }); + + test('equality works correctly', () { + const link1 = ParsedLink(value: 'news', instance: 'lemmy.world'); + const link2 = ParsedLink(value: 'news', instance: 'lemmy.world'); + const link3 = ParsedLink(value: 'other', instance: 'lemmy.world'); + expect(link1, equals(link2)); + expect(link1, isNot(equals(link3))); + }); + }); + + group('Lemmy Community Parsing', () { + test('parses full community URL with federation', () { + final result = parseLemmyCommunity('https://lemmy.world/c/news@lemmy.ml'); + expect(result, isNotNull); + expect(result!.value, 'news'); + expect(result.instance, 'lemmy.ml'); + }); + + test('parses short community URL', () { + final result = parseLemmyCommunity('https://lemmy.world/c/news'); + expect(result, isNotNull); + expect(result!.value, 'news'); + expect(result.instance, 'lemmy.world'); + }); + + test('parses community mention with !', () { + final result = parseLemmyCommunity('!news@lemmy.world'); + expect(result, isNotNull); + expect(result!.value, 'news'); + expect(result.instance, 'lemmy.world'); + }); + + test('returns null for user URLs', () { + expect(parseLemmyCommunity('https://lemmy.world/u/darklightxi'), isNull); + expect(parseLemmyCommunity('https://lemmy.world/u/darklightxi@lemmy.ca'), isNull); + }); + + test('returns null for @ mentions (users)', () { + expect(parseLemmyCommunity('@darklightxi@lemmy.world'), isNull); + }); + }); + + group('Lemmy User Parsing', () { + test('parses full user URL with federation', () { + final result = parseLemmyUser('https://lemmy.world/u/darklightxi@lemmy.ca'); + expect(result, isNotNull); + expect(result!.value, 'darklightxi'); + expect(result.instance, 'lemmy.ca'); + }); + + test('parses short user URL', () { + final result = parseLemmyUser('https://lemmy.world/u/darklightxi'); + expect(result, isNotNull); + expect(result!.value, 'darklightxi'); + expect(result.instance, 'lemmy.world'); + }); + + test('parses user mention with @', () { + final result = parseLemmyUser('@darklightxi@lemmy.world'); + expect(result, isNotNull); + expect(result!.value, 'darklightxi'); + expect(result.instance, 'lemmy.world'); + }); + + test('returns null for community URLs', () { + expect(parseLemmyUser('https://lemmy.world/c/news'), isNull); + expect(parseLemmyUser('https://lemmy.world/c/news@lemmy.ml'), isNull); + }); + + test('returns null for ! mentions (communities)', () { + expect(parseLemmyUser('!news@lemmy.world'), isNull); + }); + }); + + group('Lemmy Post Parsing', () { + test('parses post URL', () { + final result = parseLemmyPostId('https://lemmy.world/post/12345'); + expect(result, isNotNull); + expect(result!.value, '12345'); + expect(result.instance, 'lemmy.world'); + }); + + test('returns null for PieFed comment URLs', () { + expect(parseLemmyPostId('https://piefed.social/post/123/comment/456'), isNull); + }); + + test('returns null for Lemmy new format comment URLs', () { + expect(parseLemmyPostId('https://lemmy.world/post/123/456'), isNull); + }); + }); + + group('Lemmy Comment Parsing', () { + test('parses legacy comment URL', () { + final result = parseLemmyCommentId('https://lemmy.world/comment/12345'); + expect(result, isNotNull); + expect(result!.value, '12345'); + expect(result.instance, 'lemmy.world'); + }); + + test('parses new format comment URL (/post/123/456)', () { + final result = parseLemmyCommentId('https://lemmy.world/post/123/456'); + expect(result, isNotNull); + expect(result!.value, '456'); + expect(result.instance, 'lemmy.world'); + }); + + test('returns null for pure post URLs', () { + expect(parseLemmyCommentId('https://lemmy.world/post/123'), isNull); + }); + }); + + group('PieFed Comment Parsing', () { + test('parses PieFed comment URL (/post/123/comment/456)', () { + final result = parsePiefedCommentId('https://piefed.social/post/1663157/comment/9679172'); + expect(result, isNotNull); + expect(result!.value, '9679172'); + expect(result.instance, 'piefed.social'); + }); + + test('returns null for Lemmy format comment URLs', () { + expect(parsePiefedCommentId('https://lemmy.world/comment/123'), isNull); + expect(parsePiefedCommentId('https://lemmy.world/post/123/456'), isNull); + }); + }); + + group('Unified Parsers', () { + group('parseCommunity', () { + test('parses Lemmy community URL', () { + final result = parseCommunity('https://lemmy.world/c/news'); + expect(result?.qualified, 'news@lemmy.world'); + }); + + test('parses PieFed community URL', () { + final result = parseCommunity('https://piefed.social/c/news@lemmy.world'); + expect(result?.qualified, 'news@lemmy.world'); + }); + }); + + group('parseUser', () { + test('parses Lemmy user URL', () { + final result = parseUser('https://lemmy.world/u/darklightxi'); + expect(result?.qualified, 'darklightxi@lemmy.world'); + }); + + test('parses PieFed user URL', () { + final result = parseUser('https://piefed.social/u/darklightxi@lemmy.ca'); + expect(result?.qualified, 'darklightxi@lemmy.ca'); + }); + }); + + group('parsePostId', () { + test('parses Lemmy post URL', () { + final result = parsePostId('https://lemmy.world/post/12345'); + expect(result?.value, '12345'); + expect(result?.instance, 'lemmy.world'); + }); + + test('parses PieFed post URL', () { + final result = parsePostId('https://piefed.social/post/1656906'); + expect(result?.value, '1656906'); + expect(result?.instance, 'piefed.social'); + }); + + test('returns null for comment URLs', () { + expect(parsePostId('https://piefed.social/post/123/comment/456'), isNull); + expect(parsePostId('https://lemmy.world/post/123/456'), isNull); + }); + }); + + group('parseCommentId', () { + test('parses PieFed comment URL', () { + final result = parseCommentId('https://piefed.social/post/1663157/comment/9679172'); + expect(result?.value, '9679172'); + expect(result?.instance, 'piefed.social'); + }); + + test('parses Lemmy legacy comment URL', () { + final result = parseCommentId('https://lemmy.world/comment/12345'); + expect(result?.value, '12345'); + expect(result?.instance, 'lemmy.world'); + }); + + test('parses Lemmy new format comment URL', () { + final result = parseCommentId('https://lemmy.world/post/123/456'); + expect(result?.value, '456'); + expect(result?.instance, 'lemmy.world'); + }); + }); + }); + + group('Real-world PieFed URL examples from issue #2036', () { + test('https://piefed.social/post/1656906', () { + final result = parsePostId('https://piefed.social/post/1656906'); + expect(result, isNotNull); + expect(result!.value, '1656906'); + expect(result.instance, 'piefed.social'); + }); + + test('https://piefed.social/u/darklightxi@lemmy.ca', () { + final result = parseUser('https://piefed.social/u/darklightxi@lemmy.ca'); + expect(result, isNotNull); + expect(result!.value, 'darklightxi'); + expect(result.instance, 'lemmy.ca'); + }); + + test('https://piefed.social/c/news@lemmy.world', () { + final result = parseCommunity('https://piefed.social/c/news@lemmy.world'); + expect(result, isNotNull); + expect(result!.value, 'news'); + expect(result.instance, 'lemmy.world'); + }); + + test('https://piefed.social/post/1663157/comment/9679172', () { + final result = parseCommentId('https://piefed.social/post/1663157/comment/9679172'); + expect(result, isNotNull); + expect(result!.value, '9679172'); + expect(result.instance, 'piefed.social'); + }); + }); +} From e59cdf838cc979e5675c43280798bfffc7ecefca Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Wed, 14 Jan 2026 12:05:03 -0800 Subject: [PATCH 2/2] fix: fix piefed link handling with slugs --- lib/src/shared/utils/link_utils.dart | 28 +++++++++++++++++++++- test/utils/link_utils_test.dart | 35 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/src/shared/utils/link_utils.dart b/lib/src/shared/utils/link_utils.dart index 973fd1d4e..b71419cc2 100644 --- a/lib/src/shared/utils/link_utils.dart +++ b/lib/src/shared/utils/link_utils.dart @@ -75,6 +75,10 @@ final RegExp _lemmyPostCommentUrl = RegExp(r'^(https?:\/\/)(.*)/post/([0-9]+)/([ /// Groups: 2=instance, 3=postId, 4=commentId final RegExp _piefedCommentUrl = RegExp(r'^(https?:\/\/)(.*)/post/([0-9]+)/comment/([0-9]+).*$'); +/// Matches instance.tld/c/community/p/123/slug (PieFed community post format) +/// Groups: 2=instance, 3=community, 4=postId +final RegExp _piefedCommunityPostUrl = RegExp(r'^(https?:\/\/)([^/]+)/c/([^/]+)/p/([0-9]+)'); + // ============================================================================ // Lemmy Parsers // ============================================================================ @@ -92,6 +96,11 @@ ParsedLink? parseLemmyCommunity(String text) { return null; } + // Skip if this looks like a PieFed post URL (/c/community/p/postId) + if (text.contains('/p/')) { + return null; + } + // Try full community URL: /c/community@federatedInstance final fullMatch = _lemmyFullCommunityUrl.firstMatch(text); if (fullMatch != null && fullMatch.groupCount >= 4) { @@ -237,12 +246,23 @@ ParsedLink? parsePiefedUser(String text) => parseLemmyUser(text); /// /// Supports formats: /// - https://instance.tld/post/123 +/// - https://instance.tld/c/community/p/123/slug ParsedLink? parsePiefedPostId(String text) { // Skip if this is a comment URL if (text.contains('/comment/')) { return null; } + // Try PieFed community post format: /c/community/p/123/slug + final communityPostMatch = _piefedCommunityPostUrl.firstMatch(text); + if (communityPostMatch != null && communityPostMatch.groupCount >= 4) { + return ParsedLink( + value: communityPostMatch.group(4)!, + instance: communityPostMatch.group(2)!, + ); + } + + // Try standard post format: /post/123 final match = _lemmyPostUrl.firstMatch(text); if (match != null && match.groupCount >= 3) { return ParsedLink( @@ -299,7 +319,13 @@ ParsedLink? parsePostId(String text) { return null; } - // Try Lemmy format (handles both Lemmy and PieFed post URLs) + // Try PieFed format first (includes /c/community/p/123/slug format) + final piefedResult = parsePiefedPostId(text); + if (piefedResult != null) { + return piefedResult; + } + + // Try Lemmy format return parseLemmyPostId(text); } diff --git a/test/utils/link_utils_test.dart b/test/utils/link_utils_test.dart index 0c6934197..b8f30c449 100644 --- a/test/utils/link_utils_test.dart +++ b/test/utils/link_utils_test.dart @@ -47,6 +47,11 @@ void main() { test('returns null for @ mentions (users)', () { expect(parseLemmyCommunity('@darklightxi@lemmy.world'), isNull); }); + + test('returns null for PieFed post URLs (/c/community/p/postId)', () { + expect(parseLemmyCommunity('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'), isNull); + expect(parseLemmyCommunity('https://piefed.social/c/thunder_app/p/1422697'), isNull); + }); }); group('Lemmy User Parsing', () { @@ -225,5 +230,35 @@ void main() { expect(result!.value, '9679172'); expect(result.instance, 'piefed.social'); }); + + test('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support (community post format)', () { + final result = parsePostId('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); + expect(result, isNotNull); + expect(result!.value, '1422697'); + expect(result.instance, 'piefed.social'); + }); + }); + + group('PieFed Community Post URL Format', () { + test('parses /c/community/p/postId/slug format', () { + final result = parsePiefedPostId('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); + expect(result, isNotNull); + expect(result!.value, '1422697'); + expect(result.instance, 'piefed.social'); + }); + + test('parses /c/community/p/postId format (no slug)', () { + final result = parsePiefedPostId('https://piefed.social/c/thunder_app/p/1422697'); + expect(result, isNotNull); + expect(result!.value, '1422697'); + expect(result.instance, 'piefed.social'); + }); + + test('unified parsePostId handles PieFed community post format', () { + final result = parsePostId('https://piefed.social/c/thunder_app/p/1422697/thunder-release-v0-8-0-initial-piefed-support'); + expect(result, isNotNull); + expect(result!.value, '1422697'); + expect(result.instance, 'piefed.social'); + }); }); }