Skip to content

Commit 5e569d4

Browse files
DEV: Add support for glimmer topic list (#60)
This makes the theme component compatible with core's glimmer topic list, while maintaining compatibility with the raw-hbs topic list. Some parts of the logic (e.g. the masonry layout system, and the topic-list-before-link outlet) are reimplemented in a way which works in both old and new topic-lists. Other parts are re-implemented separately for the new topic list. For those parts, the legacy versions are isolated in the `topic-thumbnails-init-legacy.js` initializer for future removal.
1 parent 85ad071 commit 5e569d4

File tree

11 files changed

+546
-234
lines changed

11 files changed

+546
-234
lines changed
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import Component from "@glimmer/component";
2+
import { service } from "@ember/service";
3+
import coldAgeClass from "discourse/helpers/cold-age-class";
4+
import concatClass from "discourse/helpers/concat-class";
5+
import formatDate from "discourse/helpers/format-date";
6+
import dIcon from "discourse-common/helpers/d-icon";
7+
8+
export default class TopicListThumbnail extends Component {
9+
@service topicThumbnails;
10+
11+
responsiveRatios = [1, 1.5, 2];
12+
13+
// Make sure to update about.json thumbnail sizes if you change these variables
14+
get displayWidth() {
15+
return this.topicThumbnails.displayList
16+
? settings.list_thumbnail_size
17+
: 400;
18+
}
19+
20+
get topic() {
21+
return this.args.topic;
22+
}
23+
24+
get hasThumbnail() {
25+
return !!this.topic.thumbnails;
26+
}
27+
28+
get srcSet() {
29+
const srcSetArray = [];
30+
31+
this.responsiveRatios.forEach((ratio) => {
32+
const target = ratio * this.displayWidth;
33+
const match = this.topic.thumbnails.find(
34+
(t) => t.url && t.max_width === target
35+
);
36+
if (match) {
37+
srcSetArray.push(`${match.url} ${ratio}x`);
38+
}
39+
});
40+
41+
if (srcSetArray.length === 0) {
42+
srcSetArray.push(`${this.original.url} 1x`);
43+
}
44+
45+
return srcSetArray.join(",");
46+
}
47+
48+
get original() {
49+
return this.topic.thumbnails[0];
50+
}
51+
52+
get width() {
53+
return this.original.width;
54+
}
55+
56+
get isLandscape() {
57+
return this.original.width >= this.original.height;
58+
}
59+
60+
get height() {
61+
return this.original.height;
62+
}
63+
64+
get fallbackSrc() {
65+
const largeEnough = this.topic.thumbnails.filter((t) => {
66+
if (!t.url) {
67+
return false;
68+
}
69+
return t.max_width > this.displayWidth * this.responsiveRatios.lastObject;
70+
});
71+
72+
if (largeEnough.lastObject) {
73+
return largeEnough.lastObject.url;
74+
}
75+
76+
return this.original.url;
77+
}
78+
79+
get url() {
80+
return this.topic.get("linked_post_number")
81+
? this.topic.urlForPostNumber(this.topic.get("linked_post_number"))
82+
: this.topic.get("lastUnreadUrl");
83+
}
84+
85+
<template>
86+
<div
87+
class={{concatClass
88+
"topic-list-thumbnail"
89+
(if this.hasThumbnail "has-thumbnail" "no-thumbnail")
90+
}}
91+
>
92+
<a href={{this.url}}>
93+
{{#if this.hasThumbnail}}
94+
<img
95+
class="background-thumbnail"
96+
src={{this.fallbackSrc}}
97+
srcset={{this.srcSet}}
98+
width={{this.width}}
99+
height={{this.height}}
100+
loading="lazy"
101+
/>
102+
<img
103+
class="main-thumbnail"
104+
src={{this.fallbackSrc}}
105+
srcset={{this.srcSet}}
106+
width={{this.width}}
107+
height={{this.height}}
108+
loading="lazy"
109+
/>
110+
{{else}}
111+
<div class="thumbnail-placeholder">
112+
{{dIcon settings.placeholder_icon}}
113+
</div>
114+
{{/if}}
115+
</a>
116+
</div>
117+
118+
{{#if this.topicThumbnails.showLikes}}
119+
<div class="topic-thumbnail-likes">
120+
{{dIcon "heart"}}
121+
<span class="number">
122+
{{this.topic.like_count}}
123+
</span>
124+
</div>
125+
{{/if}}
126+
127+
{{#if this.topicThumbnails.displayBlogStyle}}
128+
<div class="topic-thumbnail-blog-data">
129+
<div class="topic-thumbnail-blog-data-views">
130+
{{dIcon "eye"}}
131+
<span class="number">
132+
{{this.topic.views}}
133+
</span>
134+
</div>
135+
<div class="topic-thumbnail-blog-data-likes">
136+
{{dIcon "heart"}}
137+
<span class="number">
138+
{{this.topic.like_count}}
139+
</span>
140+
</div>
141+
<div class="topic-thumbnail-blog-data-comments">
142+
{{dIcon "comment"}}
143+
<span class="number">
144+
{{this.topic.reply_count}}
145+
</span>
146+
</div>
147+
<div
148+
class={{concatClass
149+
"topic-thumbnail-blog-data-activity"
150+
"activity"
151+
(coldAgeClass
152+
this.topic.createdAt startDate=this.topic.bumpedAt class=""
153+
)
154+
}}
155+
title={{this.topic.bumpedAtTitle}}
156+
>
157+
<a class="post-activity" href={{this.topic.lastPostUrl}}>
158+
{{~formatDate this.topic.bumpedAt format="tiny" noTitle="true"~}}
159+
</a>
160+
</div>
161+
</div>
162+
{{/if}}
163+
</template>
164+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import Component from "@glimmer/component";
2+
import { service } from "@ember/service";
3+
import { modifier } from "ember-modifier";
4+
import MasonryCalculator from "../../lib/masonry-calculator";
5+
6+
export default class TopicListMasonryStyles extends Component {
7+
@service topicThumbnails;
8+
9+
attachResizeObserver = modifier((element) => {
10+
const topicList = element.closest(".topic-list");
11+
12+
if (!topicList) {
13+
// eslint-disable-next-line no-console
14+
console.error(
15+
"topic-list-thumbnails resize-observer must be inside a topic-list"
16+
);
17+
return;
18+
}
19+
20+
this.topicThumbnails.masonryContainerWidth =
21+
topicList.getBoundingClientRect().width;
22+
23+
const observer = new ResizeObserver(() => {
24+
this.topicThumbnails.masonryContainerWidth =
25+
topicList.getBoundingClientRect().width;
26+
});
27+
observer.observe(topicList);
28+
29+
return () => {
30+
observer.disconnect();
31+
this.topicThumbnails.masonryContainerWidth = null;
32+
};
33+
});
34+
35+
get masonryStyle() {
36+
if (!this.topicThumbnails.displayMasonry) {
37+
return;
38+
}
39+
40+
if (!this.topicThumbnails.masonryContainerWidth) {
41+
return;
42+
}
43+
44+
const calculator = new MasonryCalculator(
45+
this.topicThumbnails,
46+
this.args.outletArgs.topics,
47+
this.topicThumbnails.masonryContainerWidth
48+
);
49+
calculator.calculateMasonryLayout();
50+
return calculator.masonryStyle;
51+
}
52+
53+
<template>
54+
{{#if this.topicThumbnails.displayMasonry}}
55+
{{! template-lint-disable no-forbidden-elements }}
56+
<style {{this.attachResizeObserver}}>
57+
{{this.masonryStyle}}
58+
</style>
59+
{{/if}}
60+
</template>
61+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
{{!-- has-modern-replacement --}}
12
{{~raw "topic-list-thumbnail" topic=context.topic location="before-columns"}}

javascripts/discourse/connectors/topic-list-before-link/topic-thumbnail.hbr

Lines changed: 0 additions & 2 deletions
This file was deleted.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { readOnly } from "@ember/object/computed";
2+
import { service } from "@ember/service";
3+
import { withPluginApi } from "discourse/lib/plugin-api";
4+
import { withSilencedDeprecations } from "discourse-common/lib/deprecated";
5+
import {
6+
getResolverOption,
7+
setResolverOption,
8+
} from "discourse-common/resolver";
9+
import { observes } from "discourse-common/utils/decorators";
10+
export default {
11+
name: "topic-thumbnails-init",
12+
initialize() {
13+
withSilencedDeprecations("discourse.hbr-topic-list-overrides", () => {
14+
withPluginApi("0.8.7", (api) => this.initWithApi(api));
15+
});
16+
},
17+
18+
initWithApi(api) {
19+
api.modifyClass("component:topic-list", {
20+
pluginId: "topic-thumbnails",
21+
topicThumbnailsService: service("topic-thumbnails"),
22+
classNameBindings: [
23+
"isMinimalGrid:topic-thumbnails-minimal",
24+
"isThumbnailGrid:topic-thumbnails-grid",
25+
"isThumbnailList:topic-thumbnails-list",
26+
"isMasonryList:topic-thumbnails-masonry",
27+
"isBlogStyleGrid:topic-thumbnails-blog-style-grid",
28+
],
29+
isMinimalGrid: readOnly("topicThumbnailsService.displayMinimalGrid"),
30+
isThumbnailGrid: readOnly("topicThumbnailsService.displayGrid"),
31+
isThumbnailList: readOnly("topicThumbnailsService.displayList"),
32+
isMasonryList: readOnly("topicThumbnailsService.displayMasonry"),
33+
isBlogStyleGrid: readOnly("topicThumbnailsService.displayBlogStyle"),
34+
});
35+
36+
api.modifyClass("component:topic-list-item", {
37+
pluginId: "topic-thumbnails",
38+
topicThumbnailsService: service("topic-thumbnails"),
39+
40+
// Hack to disable the mobile topic-list-item template
41+
// Our grid styling is responsive, and uses the desktop HTML structure
42+
@observes("topic.pinned")
43+
renderTopicListItem() {
44+
const wasMobileView = getResolverOption("mobileView");
45+
if (
46+
wasMobileView &&
47+
(this.topicThumbnailsService.displayGrid ||
48+
this.topicThumbnailsService.displayMasonry ||
49+
this.topicThumbnailsService.displayMinimalGrid ||
50+
this.topicThumbnailsService.displayBlogStyle)
51+
) {
52+
setResolverOption("mobileView", false);
53+
}
54+
55+
this._super();
56+
57+
if (wasMobileView) {
58+
setResolverOption("mobileView", true);
59+
}
60+
},
61+
});
62+
63+
api.modifyClass(
64+
"component:topic-list-item",
65+
(Superclass) =>
66+
class extends Superclass {
67+
@service topicThumbnails;
68+
69+
get classNames() {
70+
const result = super.classNames;
71+
if (this.topicThumbnails.displayMasonry) {
72+
return [...result, `masonry-${this.index}`];
73+
}
74+
return result;
75+
}
76+
}
77+
);
78+
},
79+
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { readOnly } from "@ember/object/computed";
2+
import { service } from "@ember/service";
3+
import { apiInitializer } from "discourse/lib/api";
4+
import TopicListThumbnail from "../components/topic-list-thumbnail";
5+
6+
export default apiInitializer("0.8", (api) => {
7+
const ttService = api.container.lookup("service:topic-thumbnails");
8+
9+
api.registerValueTransformer("topic-list-class", ({ value }) => {
10+
if (ttService.displayMinimalGrid) {
11+
value.push("topic-thumbnails-minimal");
12+
} else if (ttService.displayGrid) {
13+
value.push("topic-thumbnails-grid");
14+
} else if (ttService.displayList) {
15+
value.push("topic-thumbnails-list");
16+
} else if (ttService.displayMasonry) {
17+
value.push("topic-thumbnails-masonry");
18+
} else if (ttService.displayBlogStyle) {
19+
value.push("topic-thumbnails-blog-style-grid");
20+
}
21+
return value;
22+
});
23+
24+
api.registerValueTransformer("topic-list-columns", ({ value: columns }) => {
25+
if (ttService.enabledForRoute && !ttService.displayList) {
26+
columns.add(
27+
"thumbnail",
28+
{ item: TopicListThumbnail },
29+
{ before: "topic" }
30+
);
31+
}
32+
return columns;
33+
});
34+
35+
api.renderInOutlet("topic-list-before-link", <template>
36+
{{#if ttService.displayList}}
37+
<TopicListThumbnail @topic={{@outletArgs.topic}} />
38+
{{/if}}
39+
</template>);
40+
41+
api.registerValueTransformer("topic-list-item-mobile-layout", ({ value }) => {
42+
if (ttService.enabledForRoute && !ttService.displayList) {
43+
// Force the desktop layout
44+
return false;
45+
}
46+
return value;
47+
});
48+
49+
api.registerValueTransformer(
50+
"topic-list-item-class",
51+
({ value, context: { index } }) => {
52+
if (ttService.displayMasonry) {
53+
value.push(`masonry-${index}`);
54+
}
55+
return value;
56+
}
57+
);
58+
59+
const siteSettings = api.container.lookup("service:site-settings");
60+
if (settings.docs_thumbnail_mode !== "none" && siteSettings.docs_enabled) {
61+
api.modifyClass("component:docs-topic-list", {
62+
pluginId: "topic-thumbnails",
63+
topicThumbnailsService: service("topic-thumbnails"),
64+
classNameBindings: [
65+
"isMinimalGrid:topic-thumbnails-minimal",
66+
"isThumbnailGrid:topic-thumbnails-grid",
67+
"isThumbnailList:topic-thumbnails-list",
68+
"isMasonryList:topic-thumbnails-masonry",
69+
"isBlogStyleGrid:topic-thumbnails-blog-style-grid",
70+
],
71+
isMinimalGrid: readOnly("topicThumbnailsService.displayMinimalGrid"),
72+
isThumbnailGrid: readOnly("topicThumbnailsService.displayGrid"),
73+
isThumbnailList: readOnly("topicThumbnailsService.displayList"),
74+
isMasonryList: readOnly("topicThumbnailsService.displayMasonry"),
75+
isBlogStyleGrid: readOnly("topicThumbnailsService.displayBlogStyle"),
76+
});
77+
}
78+
});

0 commit comments

Comments
 (0)