Skip to content

Commit a66b7bd

Browse files
committed
DEV: Add support for glimmer topic list
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 a66b7bd

File tree

10 files changed

+468
-234
lines changed

10 files changed

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