+133
-4
lib/api.dart
+133
-4
lib/api.dart
···
1
1
import 'dart:convert';
2
2
import 'dart:io';
3
3
4
+
import 'package:at_uri/at_uri.dart';
4
5
import 'package:grain/app_logger.dart';
5
6
import 'package:grain/dpop_client.dart';
6
7
import 'package:grain/main.dart';
···
9
10
import 'package:mime/mime.dart';
10
11
11
12
import './auth.dart';
13
+
import 'models/comment.dart';
12
14
import 'models/gallery.dart';
13
15
import 'models/notification.dart' as grain;
14
16
import 'models/profile.dart';
···
130
132
}
131
133
}
132
134
133
-
Future<Map<String, dynamic>> getGalleryThread({required String uri}) async {
135
+
Future<GalleryThread?> getGalleryThread({required String uri}) async {
134
136
appLogger.i('Fetching gallery thread for uri: $uri');
135
137
final response = await http.get(
136
138
Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGalleryThread?uri=$uri'),
137
139
headers: {'Content-Type': 'application/json'},
138
140
);
139
141
if (response.statusCode != 200) {
140
-
appLogger.w('Failed to fetch gallery thread: ${response.statusCode} ${response.body}');
141
-
return {};
142
+
appLogger.w('Failed to fetch gallery thread: \\${response.statusCode} \\${response.body}');
143
+
return null;
142
144
}
143
-
return jsonDecode(response.body) as Map<String, dynamic>;
145
+
final json = jsonDecode(response.body) as Map<String, dynamic>;
146
+
final gallery = Gallery.fromJson(json['gallery']);
147
+
final comments = (json['comments'] as List<dynamic>? ?? [])
148
+
.map((c) => Comment.fromJson(c as Map<String, dynamic>))
149
+
.toList();
150
+
return GalleryThread(gallery: gallery, comments: comments);
144
151
}
145
152
146
153
Future<List<grain.Notification>> getNotifications() async {
···
259
266
return null;
260
267
}
261
268
269
+
/// Polls the gallery thread until the number of comments matches [expectedCount] or timeout.
270
+
/// Returns the thread map if successful, or null if timeout.
271
+
Future<GalleryThread?> pollGalleryThreadComments({
272
+
required String galleryUri,
273
+
required int expectedCount,
274
+
Duration pollDelay = const Duration(seconds: 2),
275
+
int maxAttempts = 20,
276
+
}) async {
277
+
int attempts = 0;
278
+
GalleryThread? thread;
279
+
while (attempts < maxAttempts) {
280
+
thread = await getGalleryThread(uri: galleryUri);
281
+
if (thread != null && thread.comments.length == expectedCount) {
282
+
appLogger.i('Gallery thread $galleryUri has expected number of comments: $expectedCount');
283
+
return thread;
284
+
}
285
+
await Future.delayed(pollDelay);
286
+
attempts++;
287
+
}
288
+
appLogger.w(
289
+
'Gallery thread $galleryUri did not reach expected comments count ($expectedCount) after polling.',
290
+
);
291
+
return null;
292
+
}
293
+
262
294
/// Uploads a blob (file) to the atproto uploadBlob endpoint using DPoP authentication.
263
295
/// Returns the blob reference map on success, or null on failure.
264
296
Future<Map<String, dynamic>?> uploadBlob(File file) async {
···
386
418
appLogger.i('Created gallery item result: $result');
387
419
return result['uri'] as String?;
388
420
}
421
+
422
+
Future<String?> createComment({
423
+
required String text,
424
+
List<Map<String, dynamic>>? facets,
425
+
required String subject,
426
+
Map<String, dynamic>? focus,
427
+
String? replyTo,
428
+
}) async {
429
+
final session = await auth.getValidSession();
430
+
if (session == null) {
431
+
appLogger.w('No valid session for createComment');
432
+
return null;
433
+
}
434
+
final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk);
435
+
final issuer = session.issuer;
436
+
final did = session.subject;
437
+
final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord');
438
+
final record = {
439
+
'collection': 'social.grain.comment',
440
+
'repo': did,
441
+
'record': {
442
+
'text': text,
443
+
if (facets != null) 'facets': facets,
444
+
'subject': subject,
445
+
if (focus != null) 'focus': focus,
446
+
if (replyTo != null) 'replyTo': replyTo,
447
+
'createdAt': DateTime.now().toUtc().toIso8601String(),
448
+
},
449
+
};
450
+
appLogger.i('Creating comment: $record');
451
+
final response = await dpopClient.send(
452
+
method: 'POST',
453
+
url: url,
454
+
accessToken: session.accessToken,
455
+
headers: {'Content-Type': 'application/json'},
456
+
body: jsonEncode(record),
457
+
);
458
+
if (response.statusCode != 200 && response.statusCode != 201) {
459
+
appLogger.w('Failed to create comment: \\${response.statusCode} \\${response.body}');
460
+
return null;
461
+
}
462
+
final result = jsonDecode(response.body) as Map<String, dynamic>;
463
+
appLogger.i('Created comment result: $result');
464
+
return result['uri'] as String?;
465
+
}
466
+
467
+
/// Deletes a record by its URI using DPoP authentication.
468
+
/// Returns true on success, false on failure.
469
+
Future<bool> deleteRecord(String uri) async {
470
+
final session = await auth.getValidSession();
471
+
if (session == null) {
472
+
appLogger.w('No valid session for deleteRecord');
473
+
return false;
474
+
}
475
+
final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk);
476
+
final issuer = session.issuer;
477
+
final url = Uri.parse('$issuer/xrpc/com.atproto.repo.deleteRecord');
478
+
final repo = session.subject;
479
+
if (repo.isEmpty) {
480
+
appLogger.w('No repo (DID) available from session for deleteRecord');
481
+
return false;
482
+
}
483
+
String? collection;
484
+
String? rkey;
485
+
try {
486
+
final atUri = AtUri.parse(uri);
487
+
collection = atUri.collection.toString();
488
+
rkey = atUri.rkey;
489
+
} catch (e) {
490
+
appLogger.w('Failed to parse collection from uri: $uri');
491
+
}
492
+
if (collection == null || collection.isEmpty) {
493
+
appLogger.w('No collection found in uri: $uri');
494
+
return false;
495
+
}
496
+
final payload = {'uri': uri, 'repo': repo, 'collection': collection, 'rkey': rkey};
497
+
appLogger.i('Deleting record: $payload');
498
+
final response = await dpopClient.send(
499
+
method: 'POST',
500
+
url: url,
501
+
accessToken: session.accessToken,
502
+
headers: {'Content-Type': 'application/json'},
503
+
body: jsonEncode(payload),
504
+
);
505
+
if (response.statusCode != 200 && response.statusCode != 204) {
506
+
appLogger.w('Failed to delete record: \\${response.statusCode} \\${response.body}');
507
+
return false;
508
+
}
509
+
appLogger.i('Deleted record $uri');
510
+
return true;
511
+
}
389
512
}
390
513
391
514
final apiService = ApiService();
515
+
516
+
class GalleryThread {
517
+
final Gallery gallery;
518
+
final List<Comment> comments;
519
+
GalleryThread({required this.gallery, required this.comments});
520
+
}
+1
-1
lib/app_theme.dart
+1
-1
lib/app_theme.dart
+9
-3
lib/models/comment.dart
+9
-3
lib/models/comment.dart
···
8
8
final String? replyTo;
9
9
final String? createdAt;
10
10
final GalleryPhoto? focus;
11
+
final List<Map<String, dynamic>>? facets;
11
12
12
13
Comment({
13
14
required this.uri,
···
17
18
this.replyTo,
18
19
this.createdAt,
19
20
this.focus,
21
+
this.facets,
20
22
});
21
23
22
24
factory Comment.fromJson(Map<String, dynamic> json) {
25
+
final record = json['record'] as Map<String, dynamic>? ?? {};
23
26
return Comment(
24
27
uri: json['uri'] ?? '',
25
28
cid: json['cid'] ?? '',
26
29
author: json['author'] ?? {},
27
-
text: json['text'] ?? '',
28
-
replyTo: json['replyTo'],
29
-
createdAt: json['createdAt'],
30
+
text: json['text'] ?? record['text'] ?? '',
31
+
replyTo: json['replyTo'] ?? record['replyTo'],
32
+
createdAt: json['createdAt'] ?? record['createdAt'],
30
33
focus: json['focus'] != null ? GalleryPhoto.fromJson(json['focus']) : null,
34
+
facets:
35
+
(json['facets'] as List?)?.map((f) => Map<String, dynamic>.from(f)).toList() ??
36
+
(record['facets'] as List?)?.map((f) => Map<String, dynamic>.from(f)).toList(),
31
37
);
32
38
}
33
39
}
+6
-2
lib/models/gallery.dart
+6
-2
lib/models/gallery.dart
···
11
11
final int? favCount;
12
12
final int? commentCount;
13
13
final Map<String, dynamic>? viewer;
14
+
final List<Map<String, dynamic>>? facets;
14
15
15
16
Gallery({
16
17
required this.uri,
···
23
24
this.favCount,
24
25
this.commentCount,
25
26
this.viewer,
27
+
this.facets,
26
28
});
27
29
28
30
factory Gallery.fromJson(Map<String, dynamic> json) {
31
+
final record = json['record'] as Map<String, dynamic>? ?? {};
29
32
return Gallery(
30
33
uri: json['uri'] ?? '',
31
34
cid: json['cid'] ?? '',
32
-
title: json['record']?['title'] ?? '',
33
-
description: json['record']?['description'] ?? '',
35
+
title: record['title'] ?? '',
36
+
description: record['description'] ?? '',
34
37
items: (json['items'] as List<dynamic>? ?? [])
35
38
.map((item) => GalleryPhoto.fromJson(item as Map<String, dynamic>))
36
39
.toList(),
···
39
42
favCount: json['favCount'],
40
43
commentCount: json['commentCount'],
41
44
viewer: json['viewer'],
45
+
facets: (record['facets'] as List?)?.map((f) => Map<String, dynamic>.from(f)).toList(),
42
46
);
43
47
}
44
48
}
+424
-64
lib/screens/comments_page.dart
+424
-64
lib/screens/comments_page.dart
···
1
+
import 'package:bluesky_text/bluesky_text.dart';
1
2
import 'package:flutter/material.dart';
2
3
import 'package:grain/api.dart';
3
4
import 'package:grain/models/comment.dart';
4
5
import 'package:grain/models/gallery.dart';
6
+
import 'package:grain/screens/profile_page.dart';
5
7
import 'package:grain/utils.dart';
6
8
import 'package:grain/widgets/app_image.dart';
9
+
import 'package:grain/widgets/faceted_text.dart';
7
10
import 'package:grain/widgets/gallery_photo_view.dart';
8
11
9
12
class CommentsPage extends StatefulWidget {
···
20
23
Gallery? _gallery;
21
24
List<Comment> _comments = [];
22
25
GalleryPhoto? _selectedPhoto;
26
+
bool _showInputBar = false;
27
+
final TextEditingController _replyController = TextEditingController();
28
+
final FocusNode _replyFocusNode = FocusNode();
29
+
String? _replyTo;
23
30
24
31
@override
25
32
void initState() {
···
27
34
_fetchThread();
28
35
}
29
36
37
+
@override
38
+
void dispose() {
39
+
_replyController.dispose();
40
+
_replyFocusNode.dispose();
41
+
super.dispose();
42
+
}
43
+
30
44
Future<void> _fetchThread() async {
31
45
setState(() {
32
46
_loading = true;
33
47
_error = false;
34
48
});
35
49
try {
36
-
final data = await apiService.getGalleryThread(uri: widget.galleryUri);
50
+
final thread = await apiService.getGalleryThread(uri: widget.galleryUri);
37
51
setState(() {
38
-
_gallery = Gallery.fromJson(data['gallery']);
39
-
_comments = (data['comments'] as List<dynamic>? ?? [])
40
-
.map((c) => Comment.fromJson(c as Map<String, dynamic>))
41
-
.toList();
52
+
_gallery = thread?.gallery;
53
+
_comments = thread?.comments ?? [];
42
54
_loading = false;
43
55
});
44
56
} catch (e) {
···
49
61
}
50
62
}
51
63
64
+
void _showReplyBar({String? replyTo, String? mention}) {
65
+
setState(() {
66
+
_showInputBar = true;
67
+
_replyTo = replyTo;
68
+
});
69
+
if (mention != null && mention.isNotEmpty) {
70
+
_replyController.text = mention;
71
+
_replyController.selection = TextSelection.fromPosition(
72
+
TextPosition(offset: _replyController.text.length),
73
+
);
74
+
} else {
75
+
_replyController.clear();
76
+
}
77
+
Future.delayed(const Duration(milliseconds: 100), () {
78
+
if (mounted) FocusScope.of(context).requestFocus(_replyFocusNode);
79
+
});
80
+
}
81
+
82
+
void _hideReplyBar() {
83
+
setState(() {
84
+
_showInputBar = false;
85
+
_replyController.clear();
86
+
_replyTo = null;
87
+
});
88
+
FocusScope.of(context).unfocus();
89
+
}
90
+
91
+
Future<void> handleDeleteComment(Comment comment) async {
92
+
final confirmed = await showDialog<bool>(
93
+
context: context,
94
+
builder: (ctx) => AlertDialog(
95
+
title: const Text('Delete Comment'),
96
+
content: const Text('Are you sure you want to delete this comment?'),
97
+
actions: [
98
+
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel')),
99
+
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete')),
100
+
],
101
+
),
102
+
);
103
+
if (confirmed != true) return;
104
+
final scaffold = ScaffoldMessenger.of(context);
105
+
scaffold.removeCurrentSnackBar();
106
+
scaffold.showSnackBar(const SnackBar(content: Text('Deleting comment...')));
107
+
final deleted = await apiService.deleteRecord(comment.uri);
108
+
if (!deleted) {
109
+
scaffold.removeCurrentSnackBar();
110
+
scaffold.showSnackBar(const SnackBar(content: Text('Failed to delete comment.')));
111
+
return;
112
+
}
113
+
final expectedCount = _comments.length - 1;
114
+
final thread = await apiService.pollGalleryThreadComments(
115
+
galleryUri: widget.galleryUri,
116
+
expectedCount: expectedCount,
117
+
);
118
+
if (thread != null) {
119
+
setState(() {
120
+
_gallery = thread.gallery;
121
+
_comments = thread.comments;
122
+
});
123
+
} else {
124
+
await _fetchThread();
125
+
}
126
+
scaffold.removeCurrentSnackBar();
127
+
scaffold.showSnackBar(const SnackBar(content: Text('Comment deleted.')));
128
+
}
129
+
130
+
// Extract facets using the async BlueskyText/entities/toFacets pattern
131
+
Future<List<Map<String, dynamic>>> _extractFacets(String text) async {
132
+
final blueskyText = BlueskyText(text);
133
+
final entities = blueskyText.entities;
134
+
final facets = await entities.toFacets();
135
+
return List<Map<String, dynamic>>.from(facets);
136
+
}
137
+
52
138
@override
53
139
Widget build(BuildContext context) {
54
140
final theme = Theme.of(context);
···
65
151
),
66
152
title: Text('Comments', style: theme.appBarTheme.titleTextStyle),
67
153
),
68
-
body: _loading
69
-
? Center(
70
-
child: CircularProgressIndicator(
71
-
strokeWidth: 2,
72
-
color: theme.colorScheme.primary,
154
+
body: GestureDetector(
155
+
behavior: HitTestBehavior.translucent,
156
+
onTap: () {
157
+
if (_showInputBar) _hideReplyBar();
158
+
},
159
+
child: _loading
160
+
? Center(
161
+
child: CircularProgressIndicator(
162
+
strokeWidth: 2,
163
+
color: theme.colorScheme.primary,
164
+
),
165
+
)
166
+
: _error
167
+
? Center(child: Text('Failed to load comments.', style: theme.textTheme.bodyMedium))
168
+
: ListView(
169
+
padding: const EdgeInsets.fromLTRB(12, 12, 12, 100),
170
+
children: [
171
+
if (_gallery != null)
172
+
Text(_gallery!.title, style: theme.textTheme.titleMedium),
173
+
const SizedBox(height: 12),
174
+
_CommentsList(
175
+
comments: _comments,
176
+
onPhotoTap: (photo) {
177
+
setState(() {
178
+
_selectedPhoto = photo;
179
+
});
180
+
},
181
+
onReply: (replyTo, {mention}) =>
182
+
_showReplyBar(replyTo: replyTo, mention: mention),
183
+
onDelete: handleDeleteComment,
184
+
),
185
+
],
186
+
),
187
+
),
188
+
bottomNavigationBar: _showInputBar
189
+
? AnimatedPadding(
190
+
duration: const Duration(milliseconds: 150),
191
+
curve: Curves.easeOut,
192
+
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
193
+
child: Builder(
194
+
builder: (context) {
195
+
final keyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0;
196
+
return Padding(
197
+
padding: EdgeInsets.only(bottom: keyboardOpen ? 0 : 12),
198
+
child: Column(
199
+
mainAxisSize: MainAxisSize.min,
200
+
children: [
201
+
Divider(height: 1, thickness: 1, color: theme.dividerColor),
202
+
Container(
203
+
color: theme.colorScheme.surfaceContainer,
204
+
child: Row(
205
+
crossAxisAlignment: CrossAxisAlignment.end,
206
+
children: [
207
+
Expanded(
208
+
child: Container(
209
+
decoration: BoxDecoration(
210
+
color: theme.colorScheme.surfaceContainerHighest
211
+
.withOpacity(0.95),
212
+
borderRadius: BorderRadius.circular(18),
213
+
),
214
+
padding: const EdgeInsets.symmetric(
215
+
horizontal: 18,
216
+
vertical: 12,
217
+
),
218
+
child: ConstrainedBox(
219
+
constraints: const BoxConstraints(
220
+
minHeight: 40,
221
+
maxHeight: 120,
222
+
),
223
+
child: Scrollbar(
224
+
child: TextField(
225
+
controller: _replyController,
226
+
focusNode: _replyFocusNode,
227
+
autofocus: true,
228
+
minLines: 1,
229
+
maxLines: 5,
230
+
textInputAction: TextInputAction.newline,
231
+
decoration: const InputDecoration(
232
+
hintText: 'Write a reply...',
233
+
border: InputBorder.none,
234
+
isCollapsed: true,
235
+
),
236
+
style: theme.textTheme.bodyLarge,
237
+
onSubmitted: (value) async {
238
+
if (value.trim().isEmpty) return;
239
+
final text = value.trim();
240
+
final facets = await _extractFacets(text);
241
+
final uri = await apiService.createComment(
242
+
text: text,
243
+
subject: widget.galleryUri,
244
+
replyTo: _replyTo,
245
+
facets: facets,
246
+
);
247
+
if (uri != null) {
248
+
final thread = await apiService
249
+
.pollGalleryThreadComments(
250
+
galleryUri: widget.galleryUri,
251
+
expectedCount: _comments.length + 1,
252
+
);
253
+
if (thread != null) {
254
+
setState(() {
255
+
_gallery = thread.gallery;
256
+
_comments = thread.comments;
257
+
});
258
+
} else {
259
+
await _fetchThread();
260
+
}
261
+
}
262
+
_hideReplyBar();
263
+
},
264
+
),
265
+
),
266
+
),
267
+
),
268
+
),
269
+
const SizedBox(width: 8),
270
+
Container(
271
+
margin: const EdgeInsets.only(right: 10, bottom: 8),
272
+
decoration: BoxDecoration(
273
+
color: theme.colorScheme.primary,
274
+
borderRadius: BorderRadius.circular(16),
275
+
),
276
+
child: IconButton(
277
+
icon: Icon(Icons.send, color: theme.colorScheme.onPrimary),
278
+
onPressed: () async {
279
+
final value = _replyController.text.trim();
280
+
if (value.isEmpty) return;
281
+
final facets = await _extractFacets(value);
282
+
final uri = await apiService.createComment(
283
+
text: value,
284
+
subject: widget.galleryUri,
285
+
replyTo: _replyTo,
286
+
facets: facets,
287
+
);
288
+
if (uri != null) {
289
+
final thread = await apiService.pollGalleryThreadComments(
290
+
galleryUri: widget.galleryUri,
291
+
expectedCount: _comments.length + 1,
292
+
);
293
+
if (thread != null) {
294
+
setState(() {
295
+
_gallery = thread.gallery;
296
+
_comments = thread.comments;
297
+
});
298
+
} else {
299
+
await _fetchThread();
300
+
}
301
+
}
302
+
_hideReplyBar();
303
+
},
304
+
),
305
+
),
306
+
],
307
+
),
308
+
),
309
+
],
310
+
),
311
+
);
312
+
},
73
313
),
74
314
)
75
-
: _error
76
-
? Center(child: Text('Failed to load comments.', style: theme.textTheme.bodyMedium))
77
-
: ListView(
78
-
padding: const EdgeInsets.all(12),
79
-
children: [
80
-
if (_gallery != null) Text(_gallery!.title, style: theme.textTheme.titleMedium),
81
-
const SizedBox(height: 12),
82
-
_CommentsList(
83
-
comments: _comments,
84
-
onPhotoTap: (photo) {
85
-
setState(() {
86
-
_selectedPhoto = photo;
87
-
});
88
-
},
315
+
: Container(
316
+
color: theme.colorScheme.surface,
317
+
child: SafeArea(
318
+
child: GestureDetector(
319
+
onTap: _showReplyBar,
320
+
child: Container(
321
+
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
322
+
child: Container(
323
+
height: 44,
324
+
decoration: BoxDecoration(
325
+
color: theme.colorScheme.surfaceContainerHighest,
326
+
borderRadius: BorderRadius.circular(22),
327
+
),
328
+
child: Row(
329
+
children: [
330
+
Icon(Icons.reply, color: theme.iconTheme.color, size: 20),
331
+
const SizedBox(width: 8),
332
+
Text(
333
+
'Add a reply...',
334
+
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
335
+
),
336
+
],
337
+
),
338
+
),
339
+
),
89
340
),
90
-
],
341
+
),
91
342
),
343
+
// Show photo view overlay if needed
344
+
extendBody: true,
345
+
extendBodyBehindAppBar: false,
92
346
),
93
347
if (_selectedPhoto != null)
94
348
Positioned.fill(
···
106
360
class _CommentsList extends StatelessWidget {
107
361
final List<Comment> comments;
108
362
final void Function(GalleryPhoto photo) onPhotoTap;
109
-
const _CommentsList({required this.comments, required this.onPhotoTap});
363
+
final void Function(String replyTo, {String? mention}) onReply;
364
+
final void Function(Comment comment) onDelete;
365
+
const _CommentsList({
366
+
required this.comments,
367
+
required this.onPhotoTap,
368
+
required this.onReply,
369
+
required this.onDelete,
370
+
});
110
371
111
372
Map<String, List<Comment>> _groupReplies(List<Comment> comments) {
112
373
final repliesByParent = <String, List<Comment>>{};
···
122
383
return comments.where((c) => c.replyTo == null).toList();
123
384
}
124
385
125
-
Widget _buildCommentTree(Comment comment, Map<String, List<Comment>> repliesByParent, int depth) {
386
+
/// Returns the top-level parent for a comment (itself if already top-level)
387
+
Comment _findTopLevelParent(Comment comment, Map<String, Comment> byUri) {
388
+
var current = comment;
389
+
while (current.replyTo != null && byUri[current.replyTo!] != null) {
390
+
final parent = byUri[current.replyTo!];
391
+
if (parent == null) break;
392
+
if (parent.replyTo == null) return parent;
393
+
current = parent;
394
+
}
395
+
return current.replyTo == null ? current : byUri[current.replyTo!] ?? current;
396
+
}
397
+
398
+
Widget _buildCommentTree(
399
+
Comment comment,
400
+
Map<String, List<Comment>> repliesByParent,
401
+
int depth,
402
+
Map<String, Comment> byUri,
403
+
) {
126
404
return Padding(
127
405
padding: EdgeInsets.only(left: depth * 18.0),
128
406
child: Column(
129
407
crossAxisAlignment: CrossAxisAlignment.start,
130
408
children: [
131
-
_CommentTile(comment: comment, onPhotoTap: onPhotoTap),
409
+
_CommentTile(
410
+
comment: comment,
411
+
onPhotoTap: onPhotoTap,
412
+
onReply: (replyTo, {mention}) {
413
+
// Only two levels: replyTo should always be the top-level parent
414
+
final parent = _findTopLevelParent(comment, byUri);
415
+
onReply(parent.uri, mention: mention);
416
+
},
417
+
onDelete: onDelete,
418
+
),
132
419
if (repliesByParent[comment.uri] != null)
133
420
...repliesByParent[comment.uri]!.map(
134
-
(reply) => _buildCommentTree(reply, repliesByParent, depth + 1),
421
+
(reply) => _buildCommentTree(reply, repliesByParent, depth + 1, byUri),
135
422
),
136
423
],
137
424
),
···
143
430
final theme = Theme.of(context);
144
431
final repliesByParent = _groupReplies(comments);
145
432
final topLevel = _topLevel(comments);
433
+
final byUri = {for (final c in comments) c.uri: c};
146
434
if (comments.isEmpty) {
147
435
return Padding(
148
436
padding: const EdgeInsets.symmetric(vertical: 32),
···
156
444
}
157
445
return Column(
158
446
crossAxisAlignment: CrossAxisAlignment.start,
159
-
children: [for (final comment in topLevel) _buildCommentTree(comment, repliesByParent, 0)],
447
+
children: [
448
+
for (final comment in topLevel) _buildCommentTree(comment, repliesByParent, 0, byUri),
449
+
],
160
450
);
161
451
}
162
452
}
···
164
454
class _CommentTile extends StatelessWidget {
165
455
final Comment comment;
166
456
final void Function(GalleryPhoto photo)? onPhotoTap;
167
-
const _CommentTile({required this.comment, this.onPhotoTap});
457
+
final void Function(String replyTo, {String? mention})? onReply;
458
+
final void Function(Comment comment)? onDelete;
459
+
const _CommentTile({required this.comment, this.onPhotoTap, this.onReply, this.onDelete});
168
460
169
461
@override
170
462
Widget build(BuildContext context) {
···
194
486
author['displayName'] ?? '@${author['handle'] ?? ''}',
195
487
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
196
488
),
197
-
Text(comment.text, style: theme.textTheme.bodyMedium),
198
-
if (comment.focus != null) ...[
199
-
const SizedBox(height: 8),
200
-
Align(
201
-
alignment: Alignment.centerLeft,
202
-
child: ConstrainedBox(
203
-
constraints: const BoxConstraints(maxWidth: 180, maxHeight: 180),
204
-
child: AspectRatio(
205
-
aspectRatio: (comment.focus!.width > 0 && comment.focus!.height > 0)
206
-
? comment.focus!.width / comment.focus!.height
207
-
: 1.0,
208
-
child: ClipRRect(
209
-
borderRadius: BorderRadius.circular(8),
210
-
child: GestureDetector(
211
-
onTap: onPhotoTap != null
212
-
? () => onPhotoTap!(
213
-
GalleryPhoto(
214
-
uri: comment.focus!.uri,
215
-
cid: comment.focus!.cid,
216
-
thumb: comment.focus!.thumb,
217
-
fullsize: comment.focus!.fullsize,
218
-
alt: comment.focus!.alt,
219
-
width: comment.focus!.width,
220
-
height: comment.focus!.height,
221
-
),
222
-
)
223
-
: null,
224
-
child: AppImage(
225
-
url: comment.focus!.thumb.isNotEmpty
226
-
? comment.focus!.thumb
227
-
: comment.focus!.fullsize,
228
-
fit: BoxFit.cover,
489
+
FacetedText(
490
+
text: comment.text,
491
+
facets: comment.facets,
492
+
style: theme.textTheme.bodyMedium,
493
+
linkStyle: theme.textTheme.bodyMedium?.copyWith(
494
+
color: theme.colorScheme.primary,
495
+
fontWeight: FontWeight.w600,
496
+
),
497
+
onMentionTap: (did) {
498
+
Navigator.of(
499
+
context,
500
+
).push(MaterialPageRoute(builder: (context) => ProfilePage(did: did)));
501
+
},
502
+
onLinkTap: (url) {
503
+
// Navigator.of(
504
+
// context,
505
+
// ).push(MaterialPageRoute(builder: (context) => WebViewPage(url: url)));
506
+
},
507
+
onTagTap: (tag) {
508
+
// TODO: Implement hashtag navigation
509
+
},
510
+
),
511
+
if (comment.focus != null &&
512
+
(comment.focus!.thumb.isNotEmpty || comment.focus!.fullsize.isNotEmpty))
513
+
Padding(
514
+
padding: const EdgeInsets.only(top: 8.0),
515
+
child: Align(
516
+
alignment: Alignment.centerLeft,
517
+
child: ConstrainedBox(
518
+
constraints: const BoxConstraints(maxWidth: 180, maxHeight: 180),
519
+
child: AspectRatio(
520
+
aspectRatio: (comment.focus!.width > 0 && comment.focus!.height > 0)
521
+
? comment.focus!.width / comment.focus!.height
522
+
: 1.0,
523
+
child: ClipRRect(
524
+
borderRadius: BorderRadius.circular(8),
525
+
child: GestureDetector(
526
+
onTap: onPhotoTap != null
527
+
? () => onPhotoTap!(
528
+
GalleryPhoto(
529
+
uri: comment.focus!.uri,
530
+
cid: comment.focus!.cid,
531
+
thumb: comment.focus!.thumb,
532
+
fullsize: comment.focus!.fullsize,
533
+
alt: comment.focus!.alt,
534
+
width: comment.focus!.width,
535
+
height: comment.focus!.height,
536
+
),
537
+
)
538
+
: null,
539
+
child: AppImage(
540
+
url: comment.focus!.thumb.isNotEmpty
541
+
? comment.focus!.thumb
542
+
: comment.focus!.fullsize,
543
+
fit: BoxFit.cover,
544
+
),
229
545
),
230
546
),
231
547
),
232
548
),
233
549
),
234
550
),
235
-
],
236
551
if (comment.createdAt != null)
237
552
Text(
238
553
formatRelativeTime(comment.createdAt!),
239
554
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
240
555
),
556
+
const SizedBox(height: 4), // Add vertical spacing above the buttons
557
+
Row(
558
+
children: [
559
+
if (comment.replyTo == null)
560
+
TextButton(
561
+
style: TextButton.styleFrom(
562
+
padding: EdgeInsets.zero,
563
+
minimumSize: Size(0, 0),
564
+
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
565
+
),
566
+
onPressed: () {
567
+
final handle = comment.author['handle'] ?? '';
568
+
final mention = handle.isNotEmpty ? '@$handle ' : '';
569
+
if (onReply != null) onReply!(comment.uri, mention: mention);
570
+
},
571
+
child: Text(
572
+
'Reply',
573
+
style: theme.textTheme.bodyMedium?.copyWith(
574
+
fontWeight: FontWeight.w600,
575
+
decoration: TextDecoration.none,
576
+
),
577
+
),
578
+
),
579
+
if (comment.author['did'] == (apiService.currentUser?.did ?? '')) ...[
580
+
const SizedBox(width: 16),
581
+
TextButton(
582
+
style: TextButton.styleFrom(
583
+
padding: EdgeInsets.zero,
584
+
minimumSize: Size(0, 0),
585
+
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
586
+
),
587
+
onPressed: () {
588
+
if (onDelete != null) onDelete!(comment);
589
+
},
590
+
child: Text(
591
+
'Delete',
592
+
style: theme.textTheme.bodyMedium?.copyWith(
593
+
fontWeight: FontWeight.w600,
594
+
decoration: TextDecoration.none,
595
+
),
596
+
),
597
+
),
598
+
],
599
+
],
600
+
),
241
601
],
242
602
),
243
603
),
+21
-2
lib/screens/gallery_page.dart
+21
-2
lib/screens/gallery_page.dart
···
5
5
import 'package:grain/screens/create_gallery_page.dart';
6
6
import 'package:grain/screens/profile_page.dart';
7
7
import 'package:grain/widgets/app_image.dart';
8
+
import 'package:grain/widgets/faceted_text.dart';
8
9
import 'package:grain/widgets/gallery_action_buttons.dart';
9
10
import 'package:grain/widgets/gallery_photo_view.dart';
10
11
import 'package:grain/widgets/justified_gallery_view.dart';
···
214
215
if (gallery.description.isNotEmpty)
215
216
Padding(
216
217
padding: const EdgeInsets.symmetric(horizontal: 8).copyWith(bottom: 8),
217
-
child: Text(
218
-
gallery.description,
218
+
child: FacetedText(
219
+
text: gallery.description,
220
+
facets: gallery.facets,
219
221
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
222
+
linkStyle: theme.textTheme.bodyMedium?.copyWith(
223
+
color: theme.colorScheme.primary,
224
+
fontWeight: FontWeight.w600,
225
+
),
226
+
onMentionTap: (did) {
227
+
Navigator.of(context).push(
228
+
MaterialPageRoute(
229
+
builder: (context) => ProfilePage(did: did, showAppBar: true),
230
+
),
231
+
);
232
+
},
233
+
onLinkTap: (url) {
234
+
// TODO: Implement or use your WebViewPage
235
+
},
236
+
onTagTap: (tag) {
237
+
// TODO: Implement hashtag navigation
238
+
},
220
239
),
221
240
),
222
241
if (isLoggedIn)
+76
-3
lib/screens/profile_page.dart
+76
-3
lib/screens/profile_page.dart
···
1
+
import 'package:bluesky_text/bluesky_text.dart';
1
2
import 'package:flutter/material.dart';
2
3
import 'package:grain/api.dart';
3
4
import 'package:grain/app_theme.dart';
4
5
import 'package:grain/models/gallery.dart';
5
6
import 'package:grain/widgets/app_image.dart';
7
+
import 'package:grain/widgets/faceted_text.dart';
6
8
7
9
import 'gallery_page.dart';
8
10
···
24
26
TabController? _tabController;
25
27
bool _favsLoading = false;
26
28
bool _galleriesLoading = false;
29
+
List<Map<String, dynamic>>? _descriptionFacets;
30
+
31
+
Future<List<Map<String, dynamic>>> _extractFacets(String text) async {
32
+
final blueskyText = BlueskyText(text);
33
+
final entities = blueskyText.entities;
34
+
final facets = await entities.toFacets();
35
+
return List<Map<String, dynamic>>.from(facets);
36
+
}
27
37
28
38
@override
29
39
void initState() {
···
94
104
}
95
105
final profile = await apiService.fetchProfile(did: did);
96
106
final galleries = await apiService.fetchActorGalleries(did: did);
107
+
List<Map<String, dynamic>>? descriptionFacets;
108
+
if ((profile?.description ?? '').isNotEmpty) {
109
+
try {
110
+
final desc = profile != null ? profile.description : '';
111
+
descriptionFacets = await _extractFacets(desc);
112
+
} catch (_) {
113
+
descriptionFacets = null;
114
+
}
115
+
}
97
116
if (mounted) {
98
117
setState(() {
99
118
_profile = profile;
100
119
_galleries = galleries;
120
+
_descriptionFacets = descriptionFacets;
101
121
_loading = false;
102
122
});
103
123
}
···
211
231
),
212
232
if ((profile.description ?? '').isNotEmpty) ...[
213
233
const SizedBox(height: 16),
214
-
Text(profile.description, textAlign: TextAlign.left),
234
+
FacetedText(
235
+
text: profile.description,
236
+
facets: _descriptionFacets,
237
+
onMentionTap: (didOrHandle) {
238
+
Navigator.of(context).push(
239
+
MaterialPageRoute(
240
+
builder: (context) =>
241
+
ProfilePage(did: didOrHandle, showAppBar: true),
242
+
),
243
+
);
244
+
},
245
+
onLinkTap: (url) {
246
+
// TODO: Implement WebViewPage navigation
247
+
},
248
+
onTagTap: (tag) {
249
+
// TODO: Implement hashtag navigation
250
+
},
251
+
linkStyle: TextStyle(
252
+
color: Theme.of(context).colorScheme.primary,
253
+
fontWeight: FontWeight.w600,
254
+
),
255
+
),
215
256
],
216
257
const SizedBox(height: 24),
217
258
],
···
254
295
),
255
296
)
256
297
: _galleries.isEmpty
257
-
? const Center(child: Text('No galleries yet'))
298
+
? GridView.builder(
299
+
shrinkWrap: true,
300
+
physics: const NeverScrollableScrollPhysics(),
301
+
padding: EdgeInsets.zero,
302
+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
303
+
crossAxisCount: 3,
304
+
childAspectRatio: 3 / 4,
305
+
crossAxisSpacing: 2,
306
+
mainAxisSpacing: 2,
307
+
),
308
+
itemCount: 12, // Enough to fill the screen
309
+
itemBuilder: (context, index) {
310
+
return Container(color: theme.colorScheme.surfaceContainerHighest);
311
+
},
312
+
)
258
313
: GridView.builder(
314
+
shrinkWrap: true,
315
+
physics: const NeverScrollableScrollPhysics(),
259
316
padding: EdgeInsets.zero,
260
317
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
261
318
crossAxisCount: 3,
···
316
373
),
317
374
)
318
375
: _favs.isEmpty
319
-
? const Center(child: Text('No favorites yet'))
376
+
? GridView.builder(
377
+
shrinkWrap: true,
378
+
physics: const NeverScrollableScrollPhysics(),
379
+
padding: EdgeInsets.zero,
380
+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
381
+
crossAxisCount: 3,
382
+
childAspectRatio: 3 / 4,
383
+
crossAxisSpacing: 2,
384
+
mainAxisSpacing: 2,
385
+
),
386
+
itemCount: 12, // Enough to fill the screen
387
+
itemBuilder: (context, index) {
388
+
return Container(color: theme.colorScheme.surfaceContainerHighest);
389
+
},
390
+
)
320
391
: GridView.builder(
392
+
shrinkWrap: true,
393
+
physics: const NeverScrollableScrollPhysics(),
321
394
padding: EdgeInsets.zero,
322
395
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
323
396
crossAxisCount: 3,
+3
-6
lib/widgets/app_image.dart
+3
-6
lib/widgets/app_image.dart
···
24
24
@override
25
25
Widget build(BuildContext context) {
26
26
final theme = Theme.of(context);
27
-
final Color bgColor = theme.brightness == Brightness.dark
28
-
? Colors.grey[900]!
29
-
: Colors.grey[100]!;
30
27
if (url == null || url!.isEmpty) {
31
28
return errorWidget ??
32
29
Container(
33
30
width: width,
34
31
height: height,
35
-
color: bgColor,
32
+
color: theme.colorScheme.surface,
36
33
child: const Icon(Icons.broken_image, color: Colors.grey),
37
34
);
38
35
}
···
48
45
Container(
49
46
width: width,
50
47
height: height,
51
-
color: bgColor,
48
+
color: theme.colorScheme.surface,
52
49
// child: const Center(
53
50
// child: CircularProgressIndicator(
54
51
// strokeWidth: 2,
···
61
58
Container(
62
59
width: width,
63
60
height: height,
64
-
color: bgColor,
61
+
color: theme.colorScheme.surface,
65
62
child: const Icon(Icons.broken_image, color: Colors.grey),
66
63
),
67
64
);
+102
lib/widgets/faceted_text.dart
+102
lib/widgets/faceted_text.dart
···
1
+
import 'package:flutter/gestures.dart';
2
+
import 'package:flutter/material.dart';
3
+
4
+
class FacetedText extends StatelessWidget {
5
+
final String text;
6
+
final List<Map<String, dynamic>>? facets;
7
+
final TextStyle? style;
8
+
final TextStyle? linkStyle;
9
+
final void Function(String did)? onMentionTap;
10
+
final void Function(String url)? onLinkTap;
11
+
final void Function(String tag)? onTagTap;
12
+
13
+
const FacetedText({
14
+
super.key,
15
+
required this.text,
16
+
this.facets,
17
+
this.style,
18
+
this.linkStyle,
19
+
this.onMentionTap,
20
+
this.onLinkTap,
21
+
this.onTagTap,
22
+
});
23
+
24
+
@override
25
+
Widget build(BuildContext context) {
26
+
final theme = Theme.of(context);
27
+
final defaultStyle = style ?? theme.textTheme.bodyMedium;
28
+
final defaultLinkStyle =
29
+
linkStyle ??
30
+
defaultStyle?.copyWith(
31
+
color: theme.colorScheme.primary,
32
+
fontWeight: FontWeight.w600,
33
+
decoration: TextDecoration.underline,
34
+
);
35
+
if (facets == null || facets!.isEmpty) {
36
+
return Text(text, style: defaultStyle);
37
+
}
38
+
// Build a list of all ranges (start, end, type, data)
39
+
final List<_FacetRange> ranges = facets!.map((facet) {
40
+
final feature = facet['features']?[0] ?? {};
41
+
final type = feature['\$type'] ?? feature['type'];
42
+
return _FacetRange(
43
+
start: facet['index']?['byteStart'] ?? facet['byteStart'] ?? 0,
44
+
end: facet['index']?['byteEnd'] ?? facet['byteEnd'] ?? 0,
45
+
type: type,
46
+
data: feature,
47
+
);
48
+
}).toList();
49
+
ranges.sort((a, b) => a.start.compareTo(b.start));
50
+
int pos = 0;
51
+
final spans = <TextSpan>[];
52
+
for (final range in ranges) {
53
+
if (range.start > pos) {
54
+
spans.add(TextSpan(text: text.substring(pos, range.start), style: defaultStyle));
55
+
}
56
+
final content = text.substring(range.start, range.end);
57
+
if (range.type?.contains('mention') == true && range.data['did'] != null) {
58
+
spans.add(
59
+
TextSpan(
60
+
text: content,
61
+
style: defaultLinkStyle,
62
+
recognizer: TapGestureRecognizer()
63
+
..onTap = onMentionTap != null ? () => onMentionTap!(range.data['did']) : null,
64
+
),
65
+
);
66
+
} else if (range.type?.contains('link') == true && range.data['uri'] != null) {
67
+
spans.add(
68
+
TextSpan(
69
+
text: content,
70
+
style: defaultLinkStyle,
71
+
recognizer: TapGestureRecognizer()
72
+
..onTap = onLinkTap != null ? () => onLinkTap!(range.data['uri']) : null,
73
+
),
74
+
);
75
+
} else if (range.type?.contains('tag') == true && range.data['tag'] != null) {
76
+
spans.add(
77
+
TextSpan(
78
+
text: '#${range.data['tag']}',
79
+
style: defaultLinkStyle,
80
+
recognizer: TapGestureRecognizer()
81
+
..onTap = onTagTap != null ? () => onTagTap!(range.data['tag']) : null,
82
+
),
83
+
);
84
+
} else {
85
+
spans.add(TextSpan(text: content, style: defaultStyle));
86
+
}
87
+
pos = range.end;
88
+
}
89
+
if (pos < text.length) {
90
+
spans.add(TextSpan(text: text.substring(pos), style: defaultStyle));
91
+
}
92
+
return RichText(text: TextSpan(children: spans));
93
+
}
94
+
}
95
+
96
+
class _FacetRange {
97
+
final int start;
98
+
final int end;
99
+
final String? type;
100
+
final Map<String, dynamic> data;
101
+
_FacetRange({required this.start, required this.end, required this.type, required this.data});
102
+
}
+8
pubspec.lock
+8
pubspec.lock
···
41
41
url: "https://pub.dev"
42
42
source: hosted
43
43
version: "0.4.0"
44
+
bluesky_text:
45
+
dependency: "direct main"
46
+
description:
47
+
name: bluesky_text
48
+
sha256: a10835d17e9cfc1739ea09a5c6fc86c287aa59074c60961601f7a0cf663b1ed4
49
+
url: "https://pub.dev"
50
+
source: hosted
51
+
version: "0.7.2"
44
52
boolean_selector:
45
53
dependency: transitive
46
54
description: