Skip to content

Commit 27ef90e

Browse files
authored
Repost qoute adgustments (#195)
1 parent 83fa1c1 commit 27ef90e

4 files changed

Lines changed: 492 additions & 276 deletions

File tree

lam7a/lib/features/tweet/services/tweet_api_service_impl.dart

Lines changed: 271 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,21 @@ class TweetsApiServiceImpl implements TweetsApiService {
166166
if (raw is! Map) continue;
167167
final json = raw as Map<String, dynamic>;
168168

169+
// If this is a repost whose originalPostData itself has an
170+
// originalPostData, we're dealing with a repost of a quote. Mark the
171+
// inner original as a quote so that TweetModel.fromJsonPosts will set
172+
// isQuote=true on the quoted tweet and the UI can render its parent
173+
// tweet nested inside.
174+
final bool topIsRepost = json['isRepost'] == true;
175+
final dynamic topOriginal = json['originalPostData'];
176+
if (topIsRepost && topOriginal is Map<String, dynamic>) {
177+
final dynamic nestedOriginal = topOriginal['originalPostData'];
178+
if (nestedOriginal is Map<String, dynamic> &&
179+
topOriginal['isQuote'] == null) {
180+
topOriginal['isQuote'] = true;
181+
}
182+
}
183+
169184
// Prefer the new transformed post shape from backend (with
170185
// originalPostData for replies / reposts / quotes). This ensures
171186
// replies and quotes carry their parent tweet tree via
@@ -795,72 +810,211 @@ class TweetsApiServiceImpl implements TweetsApiService {
795810
int page,
796811
String tweetsType,
797812
) async {
798-
String endpoint = ServerConstant.tweetsForYou;
799-
Map<String, dynamic> response = await _apiService.get(
800-
endpoint: "/posts/timeline/" + tweetsType,
801-
queryParameters: {"limit": limit, "page": page},
802-
);
813+
// Ensure flags are loaded before fetching tweets
814+
if (!_isInitialized) {
815+
await _loadStoredFlags();
816+
}
803817

804-
List<dynamic> postsJson = response['data']['posts'];
818+
try {
819+
final response = await _apiService.get<Map<String, dynamic>>(
820+
endpoint: "/posts/timeline/$tweetsType",
821+
queryParameters: {"limit": limit, "page": page},
822+
);
805823

806-
List<TweetModel> tweets = postsJson.map((post) {
807-
bool isRepost = post['isRepost'] ?? false;
808-
bool isQuote = post['isQuote'] ?? false;
824+
// Backend returns: { status, message, data: { posts: [...] } }
825+
final dataWrapper = response['data'];
826+
final postsJson = (dataWrapper is Map && dataWrapper['posts'] is List)
827+
? (dataWrapper['posts'] as List)
828+
: <dynamic>[];
809829

810-
Map<String, dynamic>? originalJson;
811-
final rawOriginal = post['originalPostData'];
812-
if ((isRepost || isQuote) && rawOriginal is Map<String, dynamic>) {
813-
originalJson = rawOriginal;
814-
}
830+
final tweets = <TweetModel>[];
815831

816-
return TweetModel(
817-
id: post['postId'].toString(),
818-
body: post['text'] ?? '',
819-
820-
mediaImages: parseMedia(post['media']),
821-
mediaVideos: const [],
822-
823-
date: DateTime.parse(post['date']),
824-
likes: post['likesCount'] ?? 0,
825-
qoutes: post['commentsCount'] ?? 0,
826-
repost: post['retweetsCount'] ?? 0,
827-
comments: post['commentsCount'] ?? 0,
828-
userId: post['userId'].toString(),
829-
830-
username: post['username'],
831-
authorName: post['name'],
832-
authorProfileImage: post['avatar'],
833-
834-
isRepost: isRepost,
835-
isQuote: isQuote,
836-
837-
originalTweet: originalJson != null
838-
? TweetModel(
839-
id: originalJson['postId'].toString(),
840-
body: originalJson['text'] ?? '',
841-
842-
mediaImages: parseMedia(originalJson['media']),
843-
mediaVideos: const [],
844-
845-
date: DateTime.parse(originalJson['date']),
846-
likes: originalJson['likesCount'] ?? 0,
847-
qoutes: originalJson['commentsCount'] ?? 0,
848-
repost: originalJson['retweetsCount'] ?? 0,
849-
comments: originalJson['commentsCount'] ?? 0,
850-
userId: originalJson['userId'].toString(),
851-
852-
username: originalJson['username'],
853-
authorName: originalJson['name'],
854-
authorProfileImage: originalJson['avatar'],
855-
856-
isRepost: false,
857-
isQuote: false,
858-
)
859-
: null,
860-
);
861-
}).toList();
832+
for (final raw in postsJson) {
833+
if (raw is! Map) continue;
834+
final json = raw as Map<String, dynamic>;
835+
836+
// If this is a repost whose originalPostData itself has an
837+
// originalPostData, we're dealing with a repost of a quote. Mark the
838+
// inner original as a quote so that TweetModel.fromJsonPosts will set
839+
// isQuote=true on the quoted tweet and the UI can render its parent
840+
// tweet nested inside.
841+
final bool topIsRepost = json['isRepost'] == true;
842+
final dynamic topOriginal = json['originalPostData'];
843+
if (topIsRepost && topOriginal is Map<String, dynamic>) {
844+
final dynamic nestedOriginal = topOriginal['originalPostData'];
845+
if (nestedOriginal is Map<String, dynamic> &&
846+
topOriginal['isQuote'] == null) {
847+
topOriginal['isQuote'] = true;
848+
}
849+
}
850+
851+
// Prefer the new transformed post shape from backend (with
852+
// originalPostData for replies / reposts / quotes). This ensures
853+
// replies and quotes carry their parent tweet tree via
854+
// TweetModel.originalTweet.
855+
try {
856+
if (json.containsKey('postId') && json.containsKey('date')) {
857+
final tweet = TweetModel.fromJsonPosts(json);
858+
859+
final tweetId = tweet.id;
860+
final isLikedByMe = json['isLikedByMe'] ?? false;
861+
final isRepostedByMe = json['isRepostedByMe'] ?? false;
862+
final isRepost = json['isRepost'] ?? false;
863+
final isQuote = json['isQuote'] ?? false;
864+
865+
final existingFlags = _interactionFlags[tweetId] ?? <String, bool>{};
866+
_interactionFlags[tweetId] = {
867+
...existingFlags,
868+
'isLikedByMe': isLikedByMe,
869+
'isRepostedByMe': isRepostedByMe,
870+
'isRepost': isRepost,
871+
'isQuote': isQuote,
872+
};
873+
874+
tweets.add(tweet);
875+
continue;
876+
}
877+
} catch (e) {
878+
print(
879+
'⚠️ Failed to parse timeline post via fromJsonPosts, falling back: $e',
880+
);
881+
}
882+
883+
// Some timeline entries for reposts are lightweight wrapper objects
884+
// that don't have a top-level postId/date; instead, the full
885+
// original post (possibly a quote/reply tree) lives under
886+
// originalPostData. Normalise those into the transformed post shape
887+
// so TweetModel.fromJsonPosts can build the hierarchical chain.
888+
final bool isRepostWrapper =
889+
(json['isRepost'] == true) && !json.containsKey('postId');
890+
final originalWrapper = json['originalPostData'];
891+
if (isRepostWrapper && originalWrapper is Map<String, dynamic>) {
892+
final original = Map<String, dynamic>.from(originalWrapper);
893+
894+
final merged = <String, dynamic>{
895+
...original,
896+
};
897+
898+
// Override author fields with the reposting user's identity and
899+
// use the repost timestamp instead of the original.
900+
if (json['userId'] != null) merged['userId'] = json['userId'];
901+
if (json['username'] != null) merged['username'] = json['username'];
902+
if (json['name'] != null) merged['name'] = json['name'];
903+
if (json['avatar'] != null) merged['avatar'] = json['avatar'];
904+
if (json['date'] != null) merged['date'] = json['date'];
905+
906+
merged['isRepost'] = true;
907+
merged['originalPostData'] = original;
908+
909+
try {
910+
final tweet = TweetModel.fromJsonPosts(merged);
911+
final tweetId = tweet.id;
912+
913+
final isLikedByMe = json['isLikedByMe'] ?? false;
914+
final isRepostedByMe = json['isRepostedByMe'] ?? false;
915+
final isQuote = merged['isQuote'] ?? false;
916+
917+
final existingFlags =
918+
_interactionFlags[tweetId] ?? <String, bool>{};
919+
_interactionFlags[tweetId] = {
920+
...existingFlags,
921+
'isLikedByMe': isLikedByMe,
922+
'isRepostedByMe': isRepostedByMe,
923+
'isRepost': true,
924+
'isQuote': isQuote is bool ? isQuote : (isQuote == true),
925+
};
926+
927+
tweets.add(tweet);
928+
continue;
929+
} catch (e) {
930+
print(
931+
'⚠️ Failed to parse timeline repost wrapper via fromJsonPosts, falling back: $e',
932+
);
933+
}
934+
}
862935

863-
return tweets;
936+
// Fallback: legacy mapping for older backend shapes.
937+
final tweetId =
938+
(json['postId'] ??
939+
json['id'] ??
940+
DateTime.now().millisecondsSinceEpoch)
941+
.toString();
942+
943+
final username = json['username']?.toString();
944+
final authorName = json['name']?.toString();
945+
final authorProfileImage = json['avatar']?.toString();
946+
947+
int parseInt(dynamic v) =>
948+
v == null ? 0 : (v is int ? v : int.tryParse(v.toString()) ?? 0);
949+
950+
final likes = parseInt(json['likesCount']);
951+
final reposts = parseInt(json['retweetsCount']);
952+
final comments = parseInt(json['commentsCount']);
953+
954+
final isLikedByMe = json['isLikedByMe'] ?? false;
955+
final isRepostedByMe = json['isRepostedByMe'] ?? false;
956+
final isRepost = json['isRepost'] ?? false;
957+
final isQuote = json['isQuote'] ?? false;
958+
959+
final imageUrls = <String>[];
960+
final videoUrls = <String>[];
961+
if (json['media'] is List) {
962+
for (final m in (json['media'] as List)) {
963+
if (m is! Map) continue;
964+
final url = m['url']?.toString();
965+
final type = m['type']?.toString();
966+
if (url == null || url.isEmpty) continue;
967+
if (type == 'VIDEO') {
968+
videoUrls.add(url);
969+
} else {
970+
imageUrls.add(url);
971+
}
972+
}
973+
}
974+
975+
final mappedJson = <String, dynamic>{
976+
'id': tweetId,
977+
'userId': (json['userId'] ?? json['user_id'] ?? '0').toString(),
978+
'body': (json['text'] ?? json['content'] ?? '').toString(),
979+
'date':
980+
(json['date'] ??
981+
json['createdAt'] ??
982+
DateTime.now().toIso8601String())
983+
.toString(),
984+
'username': username,
985+
'authorName': authorName,
986+
'authorProfileImage': authorProfileImage,
987+
'likes': likes,
988+
'repost': reposts,
989+
'comments': comments,
990+
'views': 0,
991+
'qoutes': 0,
992+
'bookmarks': 0,
993+
'mediaImages': imageUrls,
994+
'mediaVideos': videoUrls,
995+
'isRepost': isRepost,
996+
'isQuote': isQuote,
997+
};
998+
999+
final tweet = TweetModel.fromJson(mappedJson);
1000+
1001+
final existingFlags = _interactionFlags[tweetId] ?? <String, bool>{};
1002+
_interactionFlags[tweetId] = {
1003+
...existingFlags,
1004+
'isLikedByMe': isLikedByMe,
1005+
'isRepostedByMe': isRepostedByMe,
1006+
'isRepost': isRepost,
1007+
'isQuote': isQuote,
1008+
};
1009+
1010+
tweets.add(tweet);
1011+
}
1012+
1013+
return tweets;
1014+
} catch (e) {
1015+
print('❌ Error fetching timeline tweets ($tweetsType): $e');
1016+
rethrow;
1017+
}
8641018
}
8651019

8661020
@override
@@ -889,6 +1043,18 @@ class TweetsApiServiceImpl implements TweetsApiService {
8891043
if (data is! List) return [];
8901044

8911045
return data.map<TweetModel>((json) {
1046+
// Prefer the transformed hierarchical post shape when available so
1047+
// reposts/quotes keep their full parent chain via originalTweet.
1048+
if (json is Map<String, dynamic>) {
1049+
try {
1050+
if (json.containsKey('postId') && json.containsKey('date')) {
1051+
return TweetModel.fromJsonPosts(json);
1052+
}
1053+
} catch (e) {
1054+
print('⚠️ Failed to parse profile post via fromJsonPosts, falling back: $e');
1055+
}
1056+
}
1057+
8921058
final isRepost = json['isRepost'] == true;
8931059
final isQuote = json['isQuote'] == true;
8941060
final original = json['originalPostData'];
@@ -897,6 +1063,34 @@ class TweetsApiServiceImpl implements TweetsApiService {
8971063
// CASE 1: REPOST → RETURN ONLY ORIGINAL TWEET
8981064
// ---------------------------------------------------------
8991065
if (isRepost && original is Map<String, dynamic>) {
1066+
// New profile shape returns a lightweight repost wrapper where the
1067+
// full post (possibly a quote/reply tree) lives under
1068+
// originalPostData. Merge that with the repost metadata and let
1069+
// TweetModel.fromJsonPosts build the hierarchical chain.
1070+
final merged = <String, dynamic>{
1071+
...Map<String, dynamic>.from(original),
1072+
};
1073+
1074+
if (json is Map<String, dynamic>) {
1075+
if (json['userId'] != null) merged['userId'] = json['userId'];
1076+
if (json['username'] != null) merged['username'] = json['username'];
1077+
if (json['name'] != null) merged['name'] = json['name'];
1078+
if (json['avatar'] != null) merged['avatar'] = json['avatar'];
1079+
if (json['date'] != null) merged['date'] = json['date'];
1080+
}
1081+
1082+
merged['isRepost'] = true;
1083+
merged['originalPostData'] = Map<String, dynamic>.from(original);
1084+
1085+
try {
1086+
return TweetModel.fromJsonPosts(merged);
1087+
} catch (e) {
1088+
print(
1089+
'⚠️ Failed to parse profile repost via fromJsonPosts, falling back: $e',
1090+
);
1091+
}
1092+
1093+
// Fallback: flat mapping using only the original post data.
9001094
return TweetModel(
9011095
id: original['postId'].toString(),
9021096
userId: original['userId'].toString(),
@@ -986,6 +1180,22 @@ class TweetsApiServiceImpl implements TweetsApiService {
9861180
if (data is! List) return [];
9871181

9881182
return data.map<TweetModel>((json) {
1183+
// Prefer the transformed hierarchical post shape (TransformedPost)
1184+
// when available so replies carry their full parent chain via
1185+
// TweetModel.originalTweet, just like in the main timeline.
1186+
if (json is Map<String, dynamic>) {
1187+
try {
1188+
if (json.containsKey('postId') && json.containsKey('date')) {
1189+
return TweetModel.fromJsonPosts(json);
1190+
}
1191+
} catch (e) {
1192+
print(
1193+
'⚠️ Failed to parse profile reply via fromJsonPosts, falling back: $e',
1194+
);
1195+
}
1196+
}
1197+
1198+
// Legacy fallback: manual mapping using the flat shape.
9891199
final original = json['originalPostData'];
9901200

9911201
// Parent tweet (what reply is replying to)

0 commit comments

Comments
 (0)