Skip to content

Commit ba90132

Browse files
Merge pull request #10 from PortableProgrammer/dev
[Enhancement] External Link Expansion Consolidates multiple external link handlers into the single ExtLinks class, enabling greater consistency in link handling. Enhancements: * Deprecate convertInlineImageLinks, replace with ExtLinks.resolveInlineLinks to conslidate code * Add *.redd.it, imgs.xkcd.com to ExtLinks domains * Properly handle imgur video/gifv within galleries * Treat selftext posts with just a link as a "link" post, and attempt to expand that link if possible * Adjust image preview height for medium-size screens. Fixes: * Inconsistent video and poster rendering between index and comments views.
2 parents d60e3a6 + 5966da3 commit ba90132

7 files changed

Lines changed: 238 additions & 80 deletions

File tree

src/extlinks.js

Lines changed: 191 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,46 @@
11
class ExtLinks {
22
static ExternalDomains = [
3-
{
3+
{ // XKCD
44
expression: `xkcd\\.com`,
55
should_fetch: true,
6-
fetch_transform: ((post) => post.url),
7-
function: function(url, data) {
8-
const returnData = {};
6+
post_only: false,
7+
fetch_transform: ((url) => url),
8+
link_data: function(url, data) {
99
// The image is located in the #comic div and is in the format "//imgs.xkcd.com/comics/.*?"
1010
const expression = /<div id=\"comic\">.*?<img src=\"(.*?imgs\.xkcd\.com\/comics\/.*?)\".*?\/>/gis;
1111
const matches = [...data.matchAll(expression)];
1212

1313
// We only care about the first match
1414
// and index 1 for the capture group
1515
if (matches[0] && matches[0][1]) {
16+
return "https:" + matches[0][1];
17+
}
18+
19+
return null;
20+
},
21+
post_data: function(url, data) {
22+
const returnData = {};
23+
24+
if (url) {
1625
returnData.post_hint = "image";
17-
returnData.url = "https:" + matches[0][1];
26+
returnData.url = url;
1827
returnData.thumbnail = returnData.url;
1928
return returnData;
2029
}
2130
return null;
2231
},
32+
inline_replacement: ((url) => `<a href="${url}"><img class="inline" src="${url}"></a>`),
2333
},
24-
{
25-
expression: `imgur\\.com`,
34+
{ // Generic imgur/reddit links
35+
// imgur.com, preview.reddi.it, i.redd.it, v.redd.it
36+
expression: `(?:imgur\\.com|(?:preview|[iv])\\.redd\\.it|imgs\\.xkcd\\.com)`,
2637
should_fetch: false,
27-
fetch_transform: ((post) => post.url),
28-
function: function(url, data) {
38+
post_only: false,
39+
fetch_transform: ((url) => url),
40+
link_data: function(url, data) {
41+
return url;
42+
},
43+
post_data: function(url, data) {
2944
const returnData = {};
3045
returnData.url = url;
3146
const expression = /(?:gifv|mp4)/i;
@@ -37,43 +52,93 @@ class ExtLinks {
3752
returnData.secure_media.reddit_video.hls_url =
3853
url.replace(expression, 'mp4');
3954
returnData.thumbnail = returnData.url;
55+
returnData.preview = { images: [ { source: { url: returnData.thumbnail }}]};
4056
returnData.post_hint = "hosted:video";
4157
} else {
4258
// Probably just a normal image
4359
returnData.post_hint = "image";
4460
}
4561
return returnData;
4662
},
63+
inline_replacement: function(url) {
64+
const expression = /(?:gifv|mp4)/i;
65+
const gifExpression = /gifv/i;
66+
if (url.match(expression)) {
67+
// Probably a gif / video
68+
// If it's a gifv, enable autoplay
69+
return `<video controls muted data-dashjs-player preload="metadata" poster="${url}"${url.match(gifExpression) ? "autoplay" : ""}><source src="${url}"></video>`;
70+
} else {
71+
// Probably just a normal image
72+
return `<a href="${url}"><img class="inline" src="${url}"></a>`;
73+
}
74+
},
4775
},
48-
{
76+
{ // Old-style imgur gallery
4977
expression: `imgur\\.com\/a\/`,
5078
should_fetch: true,
51-
fetch_transform: ((post) => post.url.includes("/gallery") ? post.url : post.url + "/embed"),
52-
function: function(url, data) {
79+
post_only: true,
80+
fetch_transform: ((url) => url.includes("/gallery") ? url : url + "/embed"),
81+
link_data: function(url, data) {
82+
return url;
83+
},
84+
post_data: function(url, data) {
5385
const returnData = {};
5486
returnData.url = url;
5587
const expression = /^\s+?images\s+?:\s+?(\{\"count\":\d+?,\"images\":\[\{.*\},?\]\})/gm;
56-
data.matchAll(expression).forEach((match) => {
57-
// Build a gallery with these images
58-
returnData.is_gallery = true;
59-
returnData.gallery_data = {
60-
items: [],
61-
};
62-
JSON.parse(match[1]).images.map((img) => {
63-
returnData.gallery_data.items.push({
64-
media_id: "https://i.imgur.com/" + img.hash + ".jpg",
88+
const matches = data.matchAll(expression);
89+
if (matches) {
90+
const matchArray = Array.from(matches);
91+
if (matchArray.length == 1) {
92+
// Single-item, don't treat it as a gallery
93+
const video_expression = /(?:gifv|mp4)/i;
94+
JSON.parse(matchArray[0][1]).images.map((img) => {
95+
if (img.ext.match(video_expression)) {
96+
// Probably a gif / video
97+
returnData.secure_media = { reddit_video: {} };
98+
returnData.secure_media.reddit_video.fallback_url =
99+
returnData.secure_media.reddit_video.dash_url =
100+
returnData.secure_media.reddit_video.hls_url =
101+
"https://i.imgur.com/" + img.hash + img.ext;
102+
returnData.thumbnail = "https://i.imgur.com/" + img.hash + ".jpg";
103+
returnData.preview = { images: [ { source: { url: returnData.thumbnail }}]};
104+
returnData.post_hint = "hosted:video";
105+
} else {
106+
// Probably just a normal image
107+
returnData.post_hint = "image";
108+
returnData.url = "https://i.imgur.com/" + img.hash + ".jpg";
109+
}
65110
});
66-
});
67-
});
111+
} else {
112+
// Build a gallery with these images
113+
returnData.is_gallery = true;
114+
returnData.gallery_data = {
115+
items: [],
116+
};
117+
matchArray.forEach((match) => {
118+
JSON.parse(match[1]).images.map((img) => {
119+
returnData.gallery_data.items.push({
120+
media_id: "https://i.imgur.com/" + img.hash + ".jpg",
121+
});
122+
});
123+
});
124+
}
125+
}
68126

69127
return returnData;
70128
},
129+
inline_replacement: function(url) {
130+
return null;
131+
},
71132
},
72-
{
133+
{ // New-style imgur gallery
73134
expression: `imgur\\.com\/gallery\/`,
74135
should_fetch: true,
75-
fetch_transform: ((post) => post.url),
76-
function: function(url, data) {
136+
post_only: true,
137+
fetch_transform: ((url) => url),
138+
link_data: function(url, data) {
139+
return url;
140+
},
141+
post_data: function(url, data) {
77142
const returnData = {};
78143
returnData.url = url;
79144
const expression = /http.{1,2}\/\/i\.imgur\.com\/\w+\.jpeg/g;
@@ -90,6 +155,9 @@ class ExtLinks {
90155

91156
return returnData;
92157
},
158+
inline_replacement: function(url) {
159+
return null;
160+
},
93161
},
94162
];
95163

@@ -115,38 +183,118 @@ class ExtLinks {
115183
.catch((err) => null);
116184
}
117185

118-
static async parseExternal(post) {
119-
const domain = ExtLinks.getExternalDomain(post.url);
186+
static async parseExternalLink(url) {
187+
const domain = ExtLinks.getExternalDomain(url);
120188
if (domain) {
189+
var fetch_data = null;
121190
if (domain.should_fetch) {
122-
const data = await ExtLinks.fetchExternal(domain.fetch_transform(post));
191+
fetch_data = await ExtLinks.fetchExternal(domain.fetch_transform(url));
192+
}
193+
194+
return domain.link_data(url, fetch_data);
195+
}
123196

124-
if (data) {
125-
const result = domain.function(post.url, data);
126-
return result;
197+
return null;
198+
}
199+
200+
static async parseExternalInlineLink(url) {
201+
const domain = ExtLinks.getExternalDomain(url);
202+
if (domain && !domain.post_only) {
203+
var fetch_data = null;
204+
if (domain.should_fetch) {
205+
fetch_data = await ExtLinks.fetchExternal(domain.fetch_transform(url));
206+
}
207+
208+
const link_data = domain.link_data(url, fetch_data);
209+
const inline_replacement = domain.inline_replacement(link_data);
210+
return inline_replacement;
211+
}
212+
213+
return null;
214+
}
215+
216+
static async parseExternalPost(post) {
217+
if (post.url) {
218+
const domain = ExtLinks.getExternalDomain(post.url);
219+
if (domain) {
220+
const link_data = await ExtLinks.parseExternalLink(post.url);
221+
if (link_data) {
222+
var fetch_data = null;
223+
224+
if (domain.post_only) {
225+
if (domain.should_fetch) {
226+
fetch_data = await ExtLinks.fetchExternal(domain.fetch_transform(post.url));
227+
}
228+
else {
229+
fetch_data = post.secure_media_embed;
230+
}
231+
}
232+
233+
return domain.post_data(link_data, fetch_data);
127234
}
128-
} else {
129-
const result = domain.function(post.url, post.secure_media_embed);
130-
return result;
131235
}
132236
}
133237

134238
return null;
135239
}
136240

241+
static async resolveExternalInlineLinks(html) {
242+
const expression = /<a href="(http[s]?:\/\/.*?)">\1?<\/a>/g;
243+
const matches = Array.from(html.matchAll(expression));
244+
245+
var result = html;
246+
for (var i = 0; i < matches.length; i++) {
247+
const match = matches[i];
248+
const replacement = await ExtLinks.parseExternalInlineLink(match[1]);
249+
if (replacement) {
250+
result = result.replace(match[0], replacement);
251+
}
252+
}
253+
254+
return result;
255+
}
256+
137257
static async resolveExternalLinks(post) {
138-
if (post.url) {
139-
const data = await ExtLinks.parseExternal(post);
258+
// First, check for a link post
259+
if (post.post_hint == 'link') {
260+
const data = await ExtLinks.parseExternalPost(post);
140261
if (data) {
141262
return data;
142263
}
143-
}
144-
return null;
145-
}
264+
}
265+
266+
// Otherwise, if the first thing in this self post is a link, or it's only a link or a group of links, treat it as a link post
267+
// Easier to perform the regex on the markdown instead of the HTML version
268+
if (post.selftext) {
269+
const expression = /^\[(.*?)\]\(\1?\)/g;
270+
const matches = Array.from(post.selftext.matchAll(expression));
271+
272+
if (matches.length > 0) {
273+
// Treat this as a link post with the first match
274+
post.post_hint = 'link';
275+
post.url = matches[0][1];
276+
const data = await ExtLinks.parseExternalPost(post);
277+
if (data) {
278+
// Strip out the matching link from the self text
279+
data.selftext = post.selftext.replace(`[${post.url}](${post.url})`, '');
280+
const expr = new RegExp(`(<|&lt;)a href="${post.url}"(>|&gt;)${post.url}(<|&lt;)/a(>|&gt;)`, "g")
281+
data.selftext_html = post.selftext_html.replace(expr, '');
282+
}
283+
284+
return data;
285+
}
286+
}
287+
288+
// Finally, find all anchors that href to known domains and contain just a link to the same href
289+
if (post.selftext_html) {
290+
const result = await ExtLinks.resolveExternalInlineLinks(post.selftext_html);
146291

147-
static updatePost(post, data) {
148-
// Update the post approrpriately
149-
post = Object.assign(post, data);
292+
if (result) {
293+
return { selftext_html: result };
294+
}
295+
}
296+
297+
return null;
150298
}
151299
}
152300

src/mixins/comment.pug

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ mixin comment(com, isfirst, parent_id, next_id, prev_id)
5252
summary.expand-comments
5353
+infoContainer(data, next_id, prev_id)
5454
div.comment-body
55-
!= convertInlineImageLinks(data.body_html)
55+
!= data.body_html
5656
if hasReplyData
5757
div.replies
5858
- var total = data.replies.data.children.length

src/mixins/post.pug

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ mixin post(p, currentUrl)
3434
h2
3535
!= p.over_18 ? 'nsfw' : 'spoiler'
3636
div.self-text.card
37-
!= convertInlineImageLinks(p.selftext_html)
37+
!= p.selftext_html
3838
if prefs.view != "card"
3939
div.media-preview(class=`${p.is_crosspost ? "crosspost" : ""}`)
4040
- var onclick = `toggleDetails('${p.id}')`

src/mixins/postUtils.pug

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,27 +59,6 @@
5959
return {};
6060
}
6161
}
62-
-
63-
function convertInlineImageLinks(html) {
64-
// Find all anchors that href to preview.redd.it, i/v.redd.it, i.imgur.com
65-
// and contain just a link to the same href
66-
const expression = /<a href="(http[s]?:\/\/(?:preview\.redd\.it|[iv]\.redd\.it|i\.imgur\.com).*?)">\1?<\/a>/g;
67-
const matches = Array.from(html.matchAll(expression));
68-
var result = html;
69-
matches.forEach((match) => {
70-
// Imgur gifv is just an MP4 video with no sound
71-
// v.redd.it is a video
72-
if (match[1].match(/(?:gifv|mp4|v\.reddi\.it)/i)) {
73-
// Replace each occurrence with a video tag
74-
result = result.replace(match[0], '<video controls muted data-dashjs-player preload="metadata" poster="' + match[1] + '"><source src="' + match[1] + '"></video>');
75-
} else {
76-
// Replace each occurrence with an actual img tag
77-
result = result.replace(match[0], '<a href="' + match[1] + '"><img class="inline" src="' + match[1] + '"></a>');
78-
}
79-
})
80-
81-
return result;
82-
}
8362
-
8463
function decodePostVideoUrls(p) {
8564
// Video and poster URLs can be HTML-encoded, so decode them.

src/public/styles.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@ form {
521521
.image-viewer img,
522522
.image-viewer video
523523
{
524-
max-height: 35vh;
524+
max-height: 45vh;
525525
}
526526
.media-preview a {
527527
font-size: 2rem;

0 commit comments

Comments
 (0)