Skip to content

Commit 756dcd1

Browse files
committed
feat: render custom emoji on status cards (closes #21)
1 parent ab942e6 commit 756dcd1

File tree

8 files changed

+153
-135
lines changed

8 files changed

+153
-135
lines changed

lib/data/custom_emoji.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/// The [CustomEmoji] class represents a custom emoji that can be used in
2+
/// Mastodon statuses.
3+
class CustomEmoji {
4+
/// The shortcode of the custom emoji.
5+
final String shortcode;
6+
7+
/// The URL of the custom emoji.
8+
final String url;
9+
10+
/// The static URL of the custom emoji.
11+
final String staticUrl;
12+
13+
CustomEmoji({
14+
required this.shortcode,
15+
required this.url,
16+
required this.staticUrl,
17+
});
18+
19+
/// Creates a [CustomEmoji] instance from a JSON object.
20+
factory CustomEmoji.fromJson(Map<String, dynamic> json) {
21+
return CustomEmoji(
22+
shortcode: json['shortcode'] as String,
23+
url: json['url'] as String,
24+
staticUrl: json['static_url'] as String,
25+
);
26+
}
27+
}

lib/data/status.dart

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:ui';
22

33
import 'package:feathr/data/account.dart';
4+
import 'package:feathr/data/custom_emoji.dart';
45
import 'package:relative_time/relative_time.dart';
56

67
/// The [StatusVisibility] enum represents the visibility of a status
@@ -52,6 +53,9 @@ class Status {
5253
// Spoiler text (content warning)
5354
final String spoilerText;
5455

56+
// Custom emojis
57+
final List<CustomEmoji> customEmojis;
58+
5559
Status({
5660
required this.id,
5761
required this.createdAt,
@@ -65,6 +69,7 @@ class Status {
6569
required this.repliesCount,
6670
required this.visibility,
6771
required this.spoilerText,
72+
required this.customEmojis,
6873
this.reblog,
6974
});
7075

@@ -85,15 +90,36 @@ class Status {
8590
visibility: StatusVisibility.values.byName(data["visibility"]!),
8691
spoilerText: data["spoiler_text"]!,
8792
reblog: data["reblog"] == null ? null : Status.fromJson(data["reblog"]),
93+
customEmojis:
94+
((data["emojis"] ?? []) as List)
95+
.map(
96+
(emoji) => CustomEmoji.fromJson(emoji as Map<String, dynamic>),
97+
)
98+
.toList(),
8899
);
89100
}
90101

102+
/// Returns the processed and augmented content of the [Status] instance,
103+
/// including a note about the reblogged status if applicable,
104+
/// and replacing custom emojis with their respective HTML tags.
91105
String getContent() {
106+
// If this status is a reblog, show the original user's account name
92107
if (reblog != null) {
93108
// TODO: display original user's avatar on reblogs
94109
return "Reblogged from ${reblog!.account.acct}: ${reblog!.getContent()}";
95110
}
96-
return content;
111+
112+
String processedContent = content;
113+
114+
// Replacing custom emojis with their respective HTML tags
115+
for (var emoji in customEmojis) {
116+
processedContent = processedContent.replaceAll(
117+
":${emoji.shortcode}:",
118+
'<img src="${emoji.staticUrl}" alt="${emoji.shortcode}" />',
119+
);
120+
}
121+
122+
return processedContent;
97123
}
98124

99125
String getRelativeDate() {

lib/services/api.dart

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import 'dart:convert';
2-
import 'dart:collection';
32

43
import 'package:http/http.dart' as http;
54
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -47,9 +46,6 @@ class ApiService {
4746
/// [http.Client] instance to perform queries (is overriden for tests)
4847
http.Client httpClient = http.Client();
4948

50-
/// Cache for custom emojis per Mastodon instance
51-
final Map<String, Map<String, String>> _customEmojisCache = HashMap();
52-
5349
/// Performs a GET request to the specified URL through the API helper
5450
Future<http.Response> _apiGet(String url) async {
5551
return await helper!.get(url, httpClient: httpClient);
@@ -361,46 +357,6 @@ class ApiService {
361357
);
362358
}
363359

364-
/// Given a Mastodon instance's base URL, requests the Mastodon API to
365-
/// return custom emojis available on that server.
366-
/// The returned data is a map of shortcode to URL for each custom emoji.
367-
/// Note that this request is not tied to the current user or its instance,
368-
/// as it's a public endpoint. Returns a list of custom emojis, if any.
369-
Future<Map<String, String>> getCustomEmojis(
370-
String mastodonInstanceUrl,
371-
) async {
372-
final apiUrl = "$mastodonInstanceUrl/api/v1/custom_emojis";
373-
http.Response resp = await _apiGet(apiUrl);
374-
375-
if (resp.statusCode == 200) {
376-
List<dynamic> jsonDataRaw = jsonDecode(resp.body);
377-
List<Map<String, dynamic>> jsonData =
378-
jsonDataRaw.map((item) => item as Map<String, dynamic>).toList();
379-
380-
return {
381-
for (var item in jsonData)
382-
item['shortcode'] as String: item['url'] as String,
383-
};
384-
}
385-
386-
throw ApiException(
387-
"Unexpected status code ${resp.statusCode} on `getCustomEmojis`",
388-
);
389-
}
390-
391-
/// Fetches custom emojis for a Mastodon instance, using cache if available
392-
Future<Map<String, String>> getCachedCustomEmojis(
393-
String mastodonInstanceUrl,
394-
) async {
395-
if (_customEmojisCache.containsKey(mastodonInstanceUrl)) {
396-
return _customEmojisCache[mastodonInstanceUrl]!;
397-
}
398-
399-
final customEmojis = await getCustomEmojis(mastodonInstanceUrl);
400-
_customEmojisCache[mastodonInstanceUrl] = customEmojis;
401-
return customEmojis;
402-
}
403-
404360
/// Given a [Status]'s ID, requests the Mastodon API to un-boost
405361
/// the status. Note that this is idempotent: a non-boosted
406362
/// status will remain non-boosted. Returns the (new) [Status] instance
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
3+
import 'package:feathr/data/custom_emoji.dart';
4+
5+
void main() {
6+
group('CustomEmoji', () {
7+
test('constructor creates an instance with the correct properties', () {
8+
const shortcode = 'thinking';
9+
const url = 'https://example.com/emoji/thinking.gif';
10+
const staticUrl = 'https://example.com/emoji/thinking.png';
11+
12+
final emoji = CustomEmoji(
13+
shortcode: shortcode,
14+
url: url,
15+
staticUrl: staticUrl,
16+
);
17+
18+
expect(emoji.shortcode, shortcode);
19+
expect(emoji.url, url);
20+
expect(emoji.staticUrl, staticUrl);
21+
});
22+
23+
test('fromJson creates a CustomEmoji from a valid JSON map', () {
24+
final jsonMap = {
25+
'shortcode': 'party',
26+
'url': 'https://example.com/emoji/party.gif',
27+
'static_url': 'https://example.com/emoji/party.png',
28+
};
29+
30+
final emoji = CustomEmoji.fromJson(jsonMap);
31+
32+
expect(emoji.shortcode, 'party');
33+
expect(emoji.url, 'https://example.com/emoji/party.gif');
34+
expect(emoji.staticUrl, 'https://example.com/emoji/party.png');
35+
});
36+
37+
test('fromJson correctly handles JSON keys', () {
38+
final jsonMap = {
39+
'shortcode': 'smile',
40+
'url': 'https://mastodon.example/emoji/smile.gif',
41+
'static_url': 'https://mastodon.example/emoji/smile.png',
42+
'additional_field': 'should be ignored',
43+
};
44+
45+
final emoji = CustomEmoji.fromJson(jsonMap);
46+
47+
expect(emoji.shortcode, 'smile');
48+
expect(emoji.url, 'https://mastodon.example/emoji/smile.gif');
49+
expect(emoji.staticUrl, 'https://mastodon.example/emoji/smile.png');
50+
});
51+
});
52+
}

test/data_test/status_test.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ void main() {
2525
"avatar": "avatar-url",
2626
"header": "header-url",
2727
},
28+
"emojis": [
29+
{
30+
"shortcode": "smile",
31+
"static_url": "https://example.com/static/smile.png",
32+
"url": "https://example.com/smile.png",
33+
},
34+
],
2835
"reblog": null,
2936
};
3037

@@ -96,6 +103,13 @@ void main() {
96103
expect(status.account.isBot, isTrue);
97104
expect(status.account.avatarUrl, equals("avatar-url"));
98105
expect(status.account.headerUrl, equals("header-url"));
106+
expect(status.customEmojis.length, equals(1));
107+
expect(status.customEmojis[0].shortcode, equals("smile"));
108+
expect(
109+
status.customEmojis[0].staticUrl,
110+
equals("https://example.com/static/smile.png"),
111+
);
112+
expect(status.customEmojis[0].url, equals("https://example.com/smile.png"));
99113
expect(status.reblog, isNull);
100114
});
101115

@@ -136,6 +150,7 @@ void main() {
136150
expect(reblog.account.isBot, isTrue);
137151
expect(reblog.account.avatarUrl, equals("avatar-url-2"));
138152
expect(reblog.account.headerUrl, equals("header-url-2"));
153+
expect(reblog.customEmojis, equals([]));
139154
});
140155

141156
testWidgets(
@@ -157,4 +172,28 @@ void main() {
157172
);
158173
},
159174
);
175+
176+
testWidgets('Status.getContent properly handles custom emoji in the content', (
177+
WidgetTester tester,
178+
) async {
179+
final statusWithEmoji = Status.fromJson({
180+
...testStatusNoReblog,
181+
"content": "<p>I am a toot! :smile:</p>",
182+
"emojis": [
183+
{
184+
"shortcode": "smile",
185+
"static_url": "https://example.com/static/smile.png",
186+
"url": "https://example.com/smile.png",
187+
},
188+
],
189+
});
190+
191+
expect(statusWithEmoji.content, equals("<p>I am a toot! :smile:</p>"));
192+
expect(
193+
statusWithEmoji.getContent(),
194+
equals(
195+
"<p>I am a toot! <img src=\"https://example.com/static/smile.png\" alt=\"smile\" /></p>",
196+
),
197+
);
198+
});
160199
}

0 commit comments

Comments
 (0)