DEV: Implement glimmer topic-list (#26743)

(experimental)

The initial implementation of glimmer topic-list and related components. Does not include new APIs and isn't compatible with existing customization. That's gonna come in future PRs.

Enabled by adding groups to `experimental_glimmer_topic_list_groups` setting.
This commit is contained in:
Jarek Radosz 2024-05-21 14:36:15 +02:00 committed by GitHub
parent eb2df2b7d6
commit 87769a83c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1795 additions and 68 deletions

View File

@ -1,18 +1,34 @@
<ConditionalLoadingSpinner @condition={{this.loading}}>
{{#if this.topics}}
<TopicList
@showPosters={{this.showPosters}}
@hideCategory={{this.hideCategory}}
@topics={{this.topics}}
@expandExcerpts={{this.expandExcerpts}}
@bulkSelectHelper={{this.bulkSelectHelper}}
@canBulkSelect={{this.canBulkSelect}}
@tagsForUser={{this.tagsForUser}}
@changeSort={{this.changeSort}}
@order={{this.order}}
@ascending={{this.ascending}}
@focusLastVisitedTopic={{this.focusLastVisitedTopic}}
/>
{{#if this.currentUser.use_glimmer_topic_list}}
<TopicList::List
@showPosters={{this.showPosters}}
@hideCategory={{this.hideCategory}}
@topics={{this.topics}}
@expandExcerpts={{this.expandExcerpts}}
@bulkSelectHelper={{this.bulkSelectHelper}}
@canBulkSelect={{this.canBulkSelect}}
@tagsForUser={{this.tagsForUser}}
@changeSort={{this.changeSort}}
@order={{this.order}}
@ascending={{this.ascending}}
@focusLastVisitedTopic={{this.focusLastVisitedTopic}}
/>
{{else}}
<TopicList
@showPosters={{this.showPosters}}
@hideCategory={{this.hideCategory}}
@topics={{this.topics}}
@expandExcerpts={{this.expandExcerpts}}
@bulkSelectHelper={{this.bulkSelectHelper}}
@canBulkSelect={{this.canBulkSelect}}
@tagsForUser={{this.tagsForUser}}
@changeSort={{this.changeSort}}
@order={{this.order}}
@ascending={{this.ascending}}
@focusLastVisitedTopic={{this.focusLastVisitedTopic}}
/>
{{/if}}
{{else}}
{{#unless this.loadingMore}}
<div class="alert alert-info">

View File

@ -8,8 +8,13 @@
{{#if this.topics}}
{{#each this.topics as |t|}}
<LatestTopicListItem @topic={{t}} />
{{#if this.currentUser.use_glimmer_topic_list}}
<TopicList::LatestTopicListItem @topic={{t}} />
{{else}}
<LatestTopicListItem @topic={{t}} />
{{/if}}
{{/each}}
<div class="more-topics">
{{#if
(eq

View File

@ -7,15 +7,27 @@
{{/if}}
{{#if @model.sharedDrafts}}
<TopicList
@listTitle="shared_drafts.title"
@top={{this.top}}
@hideCategory="true"
@category={{@category}}
@topics={{@model.sharedDrafts}}
@discoveryList={{true}}
class="shared-drafts"
/>
{{#if this.currentUser.use_glimmer_topic_list}}
<TopicList::List
@listTitle="shared_drafts.title"
@top={{this.top}}
@hideCategory="true"
@category={{@category}}
@topics={{@model.sharedDrafts}}
@discoveryList={{true}}
class="shared-drafts"
/>
{{else}}
<TopicList
@listTitle="shared_drafts.title"
@top={{this.top}}
@hideCategory="true"
@category={{@category}}
@topics={{@model.sharedDrafts}}
@discoveryList={{true}}
class="shared-drafts"
/>
{{/if}}
{{/if}}
<DiscoveryTopicsList
@ -75,31 +87,59 @@
</span>
{{#if this.hasTopics}}
<TopicList
@highlightLastVisited={{true}}
@top={{this.top}}
@hot={{this.hot}}
@showTopicPostBadges={{this.showTopicPostBadges}}
@showPosters={{true}}
@canBulkSelect={{@canBulkSelect}}
@bulkSelectHelper={{@bulkSelectHelper}}
@changeSort={{@changeSort}}
@hideCategory={{@model.hideCategory}}
@order={{this.order}}
@ascending={{this.ascending}}
@expandGloballyPinned={{this.expandGloballyPinned}}
@expandAllPinned={{this.expandAllPinned}}
@category={{@category}}
@topics={{@model.topics}}
@discoveryList={{true}}
@focusLastVisitedTopic={{true}}
@showTopicsAndRepliesToggle={{this.showTopicsAndRepliesToggle}}
@newListSubset={{@model.params.subset}}
@changeNewListSubset={{@changeNewListSubset}}
@newRepliesCount={{this.newRepliesCount}}
@newTopicsCount={{this.newTopicsCount}}
/>
{{#if this.currentUser.use_glimmer_topic_list}}
<TopicList::List
@highlightLastVisited={{true}}
@top={{this.top}}
@hot={{this.hot}}
@showTopicPostBadges={{this.showTopicPostBadges}}
@showPosters={{true}}
@canBulkSelect={{@canBulkSelect}}
@bulkSelectHelper={{@bulkSelectHelper}}
@changeSort={{@changeSort}}
@hideCategory={{@model.hideCategory}}
@order={{this.order}}
@ascending={{this.ascending}}
@expandGloballyPinned={{this.expandGloballyPinned}}
@expandAllPinned={{this.expandAllPinned}}
@category={{@category}}
@topics={{@model.topics}}
@discoveryList={{true}}
@focusLastVisitedTopic={{true}}
@showTopicsAndRepliesToggle={{this.showTopicsAndRepliesToggle}}
@newListSubset={{@model.params.subset}}
@changeNewListSubset={{@changeNewListSubset}}
@newRepliesCount={{this.newRepliesCount}}
@newTopicsCount={{this.newTopicsCount}}
/>
{{else}}
<TopicList
@highlightLastVisited={{true}}
@top={{this.top}}
@hot={{this.hot}}
@showTopicPostBadges={{this.showTopicPostBadges}}
@showPosters={{true}}
@canBulkSelect={{@canBulkSelect}}
@bulkSelectHelper={{@bulkSelectHelper}}
@changeSort={{@changeSort}}
@hideCategory={{@model.hideCategory}}
@order={{this.order}}
@ascending={{this.ascending}}
@expandGloballyPinned={{this.expandGloballyPinned}}
@expandAllPinned={{this.expandAllPinned}}
@category={{@category}}
@topics={{@model.topics}}
@discoveryList={{true}}
@focusLastVisitedTopic={{true}}
@showTopicsAndRepliesToggle={{this.showTopicsAndRepliesToggle}}
@newListSubset={{@model.params.subset}}
@changeNewListSubset={{@changeNewListSubset}}
@newRepliesCount={{this.newRepliesCount}}
@newTopicsCount={{this.newTopicsCount}}
/>
{{/if}}
{{/if}}
<span>
<PluginOutlet
@name="after-topic-list"

View File

@ -1,9 +1,13 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import NewListHeaderControls from "discourse/components/topic-list/new-list-header-controls";
import raw from "discourse/helpers/raw";
export default class NewListHeaderControlsWrapper extends Component {
@service currentUser;
@action
click(e) {
const target = e.target;
@ -17,18 +21,30 @@ export default class NewListHeaderControlsWrapper extends Component {
}
<template>
<div
{{! template-lint-disable no-invalid-interactive }}
{{on "click" this.click}}
class="topic-replies-toggle-wrapper"
>
{{raw
"list/new-list-header-controls"
current=@current
newRepliesCount=@newRepliesCount
newTopicsCount=@newTopicsCount
noStaticLabel=true
}}
</div>
{{#if this.currentUser.use_glimmer_topic_list}}
<div class="topic-replies-toggle-wrapper">
<NewListHeaderControls
@current={{@current}}
@newRepliesCount={{@newRepliesCount}}
@newTopicsCount={{@newTopicsCount}}
@noStaticLabel={{true}}
@changeNewListSubset={{@changeNewListSubset}}
/>
</div>
{{else}}
<div
{{! template-lint-disable no-invalid-interactive }}
{{on "click" this.click}}
class="topic-replies-toggle-wrapper"
>
{{raw
"list/new-list-header-controls"
current=@current
newRepliesCount=@newRepliesCount
newTopicsCount=@newTopicsCount
noStaticLabel=true
}}
</div>
{{/if}}
</template>
}

View File

@ -71,7 +71,11 @@
{{#if this.showTopics}}
<td class="latest">
{{#each this.category.featuredTopics as |t|}}
<FeaturedTopic @topic={{t}} />
{{#if this.currentUser.use_glimmer_topic_list}}
<TopicList::FeaturedTopic @topic={{t}} />
{{else}}
<FeaturedTopic @topic={{t}} />
{{/if}}
{{/each}}
</td>
{{/if}}

View File

@ -10,12 +10,14 @@ import TopicBulkActions from "./modal/topic-bulk-actions";
export default Component.extend(LoadMore, {
modal: service(),
router: service(),
siteSettings: service(),
tagName: "table",
classNames: ["topic-list"],
classNameBindings: ["bulkSelectEnabled:sticky-header"],
showTopicPostBadges: true,
listTitle: "topic.title",
lastCheckedElementId: null,
get canDoBulkActions() {
return (

View File

@ -0,0 +1,14 @@
import icon from "discourse-common/helpers/d-icon";
const ActionList = <template>
{{#if @postNumbers}}
<div class="post-actions" ...attributes>
{{icon @icon}}
{{#each @postNumbers as |postNumber|}}
<a href="{{@topic.url}}/{{postNumber}}">#{{postNumber}}</a>
{{/each}}
</div>
{{/if}}
</template>;
export default ActionList;

View File

@ -0,0 +1,37 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import PluginOutlet from "discourse/components/plugin-outlet";
import coldAgeClass from "discourse/helpers/cold-age-class";
import concatClass from "discourse/helpers/concat-class";
import element from "discourse/helpers/element";
import formatDate from "discourse/helpers/format-date";
export default class ActivityColumn extends Component {
@service siteSettings;
get wrapperElement() {
return element(this.args.tagName ?? "td");
}
<template>
<this.wrapperElement
title={{htmlSafe @topic.bumpedAtTitle}}
class={{concatClass
"activity"
(coldAgeClass @topic.createdAt startDate=@topic.bumpedAt class="")
}}
...attributes
>
<a
href={{@topic.lastPostUrl}}
class="post-activity"
>{{! no whitespace
}}<PluginOutlet
@name="topic-list-before-relative-date"
/>
{{~formatDate @topic.bumpedAt format="tiny" noTitle="true"~}}
</a>
</this.wrapperElement>
</template>
}

View File

@ -0,0 +1,43 @@
import { on } from "@ember/modifier";
import { htmlSafe } from "@ember/template";
import TopicEntrance from "discourse/components/topic-list/topic-entrance";
import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status";
import formatAge from "discourse/helpers/format-age";
import { modKeysPressed } from "discourse/lib/utilities";
const onTimestampClick = function (event) {
if (modKeysPressed(event).length) {
// Allow opening the link in a new tab/window
event.stopPropagation();
} else {
// Otherwise only display the TopicEntrance component
event.preventDefault();
}
};
const FeaturedTopic = <template>
<div data-topic-id={{@topic.id}} class="featured-topic --glimmer">
<TopicStatus @topic={{@topic}} />
<a href={{@topic.lastUnreadUrl}} class="title">{{htmlSafe
@topic.fancyTitle
}}</a>
<TopicPostBadges
@unreadPosts={{@topic.unread_posts}}
@unseen={{@topic.unseen}}
@url={{@topic.lastUnreadUrl}}
/>
<TopicEntrance @topic={{@topic}}>
<a
{{on "click" onTimestampClick}}
href={{@topic.lastPostUrl}}
class="last-posted-at"
>{{formatAge @topic.last_posted_at}}</a>
</TopicEntrance>
</div>
</template>;
export default FeaturedTopic;

View File

@ -0,0 +1,94 @@
import Component from "@glimmer/component";
import { concat, hash } from "@ember/helper";
import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet";
import PostsCountColumn from "discourse/components/topic-list/posts-count-column";
import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status";
import UserAvatarFlair from "discourse/components/user-avatar-flair";
import UserLink from "discourse/components/user-link";
import avatar from "discourse/helpers/avatar";
import categoryLink from "discourse/helpers/category-link";
import concatClass from "discourse/helpers/concat-class";
import discourseTags from "discourse/helpers/discourse-tags";
import formatDate from "discourse/helpers/format-date";
import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import topicLink from "discourse/helpers/topic-link";
export default class LatestTopicListItem extends Component {
@service appEvents;
get tagClassNames() {
if (this.args.topic.tags) {
return this.args.topic.tags.map((tagName) => `tag-${tagName}`);
}
}
<template>
<div
data-topic-id={{@topic.id}}
class={{concatClass
"latest-topic-list-item"
this.tagClassNames
(if @topic.category (concat "category-" @topic.category.fullSlug))
(if @topic.liked "liked")
(if @topic.archived "archived")
(if @topic.bookmarked "bookmarked")
(if @topic.pinned "pinned")
(if @topic.closed "closed")
(if @topic.visited "visited")
}}
>
<PluginOutlet
@name="above-latest-topic-list-item"
@connectorTagName="div"
@outletArgs={{hash topic=@topic}}
/>
<div class="topic-poster">
<UserLink @user={{@topic.lastPosterUser}}>
{{avatar @topic.lastPosterUser imageSize="large"}}
</UserLink>
<UserAvatarFlair @user={{@topic.lastPosterUser}} />
</div>
<div class="main-link">
<div class="top-row">
<TopicStatus @topic={{@topic}} />
{{topicLink @topic}}
{{~#if @topic.featured_link}}
&nbsp;{{topicFeaturedLink @topic}}
{{/if~}}
<TopicPostBadges
@unreadPosts={{@topic.unread_posts}}
@unseen={{@topic.unseen}}
@url={{@topic.lastUnreadUrl}}
/>
</div>
<div class="bottom-row">
{{categoryLink @topic.category~}}
{{~discourseTags @topic mode="list"}}
</div>
</div>
<div class="topic-stats">
<PluginOutlet
@name="above-latest-topic-list-item-post-count"
@connectorTagName="div"
@outletArgs={{hash topic=@topic}}
/>
<PostsCountColumn @topic={{@topic}} @tagName="div" />
<div class="topic-last-activity">
<a
href={{@topic.lastPostUrl}}
title={{@topic.bumpedAtTitle}}
>{{formatDate @topic.bumpedAt format="tiny" noTitle="true"}}</a>
</div>
</div>
</div>
</template>
}

View File

@ -0,0 +1,190 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper";
import { service } from "@ember/service";
import { eq, or } from "truth-helpers";
import PluginOutlet from "discourse/components/plugin-outlet";
import TopicListHeader from "discourse/components/topic-list/topic-list-header";
import TopicListItem from "discourse/components/topic-list/topic-list-item";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
export default class TopicList extends Component {
@service currentUser;
@service router;
@service siteSettings;
@tracked lastCheckedElementId;
get selected() {
return this.args.bulkSelectHelper?.selected;
}
get bulkSelectEnabled() {
return this.args.bulkSelectHelper?.bulkSelectEnabled;
}
get canDoBulkActions() {
return this.currentUser?.canManageTopic && this.selected?.length;
}
get toggleInTitle() {
return !this.bulkSelectEnabled && this.args.canBulkSelect;
}
get experimentalTopicBulkActionsEnabled() {
return this.currentUser?.use_experimental_topic_bulk_actions;
}
get sortable() {
return !!this.args.changeSort;
}
get showLikes() {
return this.args.order === "likes";
}
get showOpLikes() {
return this.args.order === "op_likes";
}
get lastVisitedTopic() {
const { topics, order, ascending, top, hot } = this.args;
if (
!this.args.highlightLastVisited ||
top ||
hot ||
ascending ||
!topics ||
topics.length === 1 ||
(order && order !== "activity") ||
!this.currentUser?.get("previous_visit_at")
) {
return;
}
// work backwards
// this is more efficient cause we keep appending to list
const start = topics.findIndex((topic) => !topic.get("pinned"));
let lastVisitedTopic, topic;
for (let i = topics.length - 1; i >= start; i--) {
if (topics[i].get("bumpedAt") > this.currentUser.get("previousVisitAt")) {
lastVisitedTopic = topics[i];
break;
}
topic = topics[i];
}
if (!lastVisitedTopic || !topic) {
return;
}
// end of list that was scanned
if (topic.get("bumpedAt") > this.currentUser.get("previousVisitAt")) {
return;
}
return lastVisitedTopic;
}
<template>
{{! template-lint-disable table-groups }}
<table
class={{concatClass
"topic-list"
(if this.bulkSelectEnabled "sticky-header")
}}
>
<thead class="topic-list-header">
<TopicListHeader
@canBulkSelect={{@canBulkSelect}}
@toggleInTitle={{this.toggleInTitle}}
@category={{@category}}
@hideCategory={{@hideCategory}}
@showPosters={{@showPosters}}
@showLikes={{this.showLikes}}
@showOpLikes={{this.showOpLikes}}
@order={{@order}}
@changeSort={{@changeSort}}
@ascending={{@ascending}}
@sortable={{this.sortable}}
@listTitle={{or @listTitle "topic.title"}}
@bulkSelectEnabled={{this.bulkSelectEnabled}}
@bulkSelectHelper={{@bulkSelectHelper}}
@experimentalTopicBulkActionsEnabled={{this.experimentalTopicBulkActionsEnabled}}
@canDoBulkActions={{this.canDoBulkActions}}
@showTopicsAndRepliesToggle={{@showTopicsAndRepliesToggle}}
@newListSubset={{@newListSubset}}
@newRepliesCount={{@newRepliesCount}}
@newTopicsCount={{@newTopicsCount}}
@changeNewListSubset={{@changeNewListSubset}}
/>
</thead>
<PluginOutlet
@name="before-topic-list-body"
@outletArgs={{hash
topics=@topics
selected=this.selected
bulkSelectEnabled=this.bulkSelectEnabled
lastVisitedTopic=this.lastVisitedTopic
discoveryList=@discoveryList
hideCategory=@hideCategory
}}
/>
<tbody class="topic-list-body">
{{#each @topics as |topic index|}}
<TopicListItem
@topic={{topic}}
@bulkSelectEnabled={{this.bulkSelectEnabled}}
@showTopicPostBadges={{@showTopicPostBadges}}
@hideCategory={{@hideCategory}}
@showPosters={{@showPosters}}
@showLikes={{this.showLikes}}
@showOpLikes={{this.showOpLikes}}
@expandGloballyPinned={{@expandGloballyPinned}}
@expandAllPinned={{@expandAllPinned}}
@lastVisitedTopic={{this.lastVisitedTopic}}
@selected={{this.selected}}
@lastCheckedElementId={{this.lastCheckedElementId}}
@updateLastCheckedElementId={{fn (mut this.lastCheckedElementId)}}
@tagsForUser={{@tagsForUser}}
@focusLastVisitedTopic={{@focusLastVisitedTopic}}
@index={{index}}
/>
{{#if (eq topic this.lastVisitedTopic)}}
<tr class="topic-list-item-separator">
<td class="topic-list-data" colspan="6">
<span>
{{i18n "topics.new_messages_marker"}}
</span>
</td>
</tr>
{{/if}}
<PluginOutlet
@name="after-topic-list-item"
@outletArgs={{hash topic=topic index=index}}
@connectorTagName="tr"
/>
{{/each}}
</tbody>
<PluginOutlet
@name="after-topic-list-body"
@outletArgs={{hash
topics=@topics
selected=this.selected
bulkSelectEnabled=this.bulkSelectEnabled
lastVisitedTopic=this.lastVisitedTopic
discoveryList=@discoveryList
hideCategory=@hideCategory
}}
/>
</table>
</template>
}

View File

@ -0,0 +1,90 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
export default class NewListHeaderControls extends Component {
get topicsActive() {
return this.args.current === "topics";
}
get repliesActive() {
return this.args.current === "replies";
}
get allActive() {
return !this.topicsActive && !this.repliesActive;
}
get repliesButtonLabel() {
if (this.args.newRepliesCount > 0) {
return i18n("filters.new.replies_with_count", {
count: this.args.newRepliesCount,
});
} else {
return i18n("filters.new.replies");
}
}
get topicsButtonLabel() {
if (this.args.newTopicsCount > 0) {
return i18n("filters.new.topics_with_count", {
count: this.args.newTopicsCount,
});
} else {
return i18n("filters.new.topics");
}
}
get staticLabel() {
if (
this.args.noStaticLabel ||
(this.args.newTopicsCount > 0 && this.args.newRepliesCount > 0)
) {
return;
}
if (this.args.newTopicsCount > 0) {
return this.topicsButtonLabel;
} else {
return this.repliesButtonLabel;
}
}
<template>
{{#if this.staticLabel}}
<span class="static-label">{{this.staticLabel}}</span>
{{else}}
<button
{{on "click" (fn @changeNewListSubset null)}}
class={{concatClass
"topics-replies-toggle --all"
(if this.allActive "active")
}}
>
{{i18n "filters.new.all"}}
</button>
<button
{{on "click" (fn @changeNewListSubset "topics")}}
class={{concatClass
"topics-replies-toggle --topics"
(if this.topicsActive "active")
}}
>
{{this.topicsButtonLabel}}
</button>
<button
{{on "click" (fn @changeNewListSubset "replies")}}
class={{concatClass
"topics-replies-toggle --replies"
(if this.repliesActive "active")
}}
>
{{this.repliesButtonLabel}}
</button>
{{/if}}
</template>
}

View File

@ -0,0 +1,25 @@
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
const ParticipantGroups = <template>
<div
role="list"
aria-label={{i18n "topic.participant_groups"}}
class="participant-group-wrapper"
>
{{#each @groups as |group|}}
<div class="participant-group">
<a
href={{group.url}}
data-group-card={{group.name}}
class="user-group trigger-group-card"
>
{{icon "users"}}
{{group.name}}
</a>
</div>
{{/each}}
</div>
</template>;
export default ParticipantGroups;

View File

@ -0,0 +1,17 @@
import { and } from "truth-helpers";
import PostsCountColumn from "discourse/components/topic-list/posts-count-column";
import TopicPostBadges from "discourse/components/topic-post-badges";
const PostCountOrBadges = <template>
{{#if (and @postBadgesEnabled @topic.unread_posts)}}
<TopicPostBadges
@unreadPosts={{@topic.unread_posts}}
@unseen={{@topic.unseen}}
@url={{@topic.lastUnreadUrl}}
/>
{{else}}
<PostsCountColumn @topic={{@topic}} @tagName="div" />
{{/if}}
</template>;
export default PostCountOrBadges;

View File

@ -0,0 +1,25 @@
import avatar from "discourse/helpers/avatar";
const PostersColumn = <template>
<td class="posters topic-list-data">
{{#each @posters as |poster|}}
{{#if poster.moreCount}}
<a class="posters-more-count">{{poster.moreCount}}</a>
{{else}}
<a
href={{poster.user.path}}
data-user-card={{poster.user.username}}
class={{poster.extraClasses}}
>{{avatar
poster
avatarTemplatePath="user.avatar_template"
usernamePath="user.username"
namePath="user.name"
imageSize="small"
}}</a>
{{/if}}
{{/each}}
</td>
</template>;
export default PostersColumn;

View File

@ -0,0 +1,67 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import PluginOutlet from "discourse/components/plugin-outlet";
import TopicEntrance from "discourse/components/topic-list/topic-entrance";
import element from "discourse/helpers/element";
import number from "discourse/helpers/number";
import I18n from "discourse-i18n";
export default class PostsCountColumn extends Component {
@service siteSettings;
get ratio() {
const likes = parseFloat(this.args.topic.like_count);
const posts = parseFloat(this.args.topic.posts_count);
if (posts < 10) {
return 0;
}
return (likes || 0) / posts;
}
get title() {
return I18n.messageFormat("posts_likes_MF", {
count: this.args.topic.replyCount,
ratio: this.ratioText,
}).trim();
}
get ratioText() {
if (this.ratio > this.siteSettings.topic_post_like_heat_high) {
return "high";
}
if (this.ratio > this.siteSettings.topic_post_like_heat_medium) {
return "med";
}
if (this.ratio > this.siteSettings.topic_post_like_heat_low) {
return "low";
}
return "";
}
get likesHeat() {
if (this.ratioText?.length) {
return `heatmap-${this.ratioText}`;
}
}
get wrapperElement() {
return element(this.args.tagName ?? "td");
}
<template>
<this.wrapperElement
class="num posts-map posts {{this.likesHeat}} topic-list-data"
>
<TopicEntrance
@topic={{@topic}}
@title={{this.title}}
@triggerClass="btn-link posts-map badge-posts {{this.likesHeat}}"
>
<PluginOutlet @name="topic-list-before-reply-count" />
{{number @topic.replyCount noTitle="true"}}
</TopicEntrance>
</this.wrapperElement>
</template>
}

View File

@ -0,0 +1,16 @@
import BulkSelectTopicsDropdown from "discourse/components/bulk-select-topics-dropdown";
import i18n from "discourse-common/helpers/i18n";
const TopicBulkSelectDropdown = <template>
<div class="bulk-select-topics-dropdown">
<span class="bulk-select-topic-dropdown__count">
{{i18n
"topics.bulk.selected_count"
count=@bulkSelectHelper.selected.length
}}
</span>
<BulkSelectTopicsDropdown @bulkSelectHelper={{@bulkSelectHelper}} />
</div>
</template>;
export default TopicBulkSelectDropdown;

View File

@ -0,0 +1,101 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import DiscourseURL from "discourse/lib/url";
import icon from "discourse-common/helpers/d-icon";
import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
function entranceDate(dt, showTime) {
const today = new Date();
if (dt.toDateString() === today.toDateString()) {
return moment(dt).format(I18n.t("dates.time"));
}
if (dt.getYear() === today.getYear()) {
// No year
return moment(dt).format(
showTime
? I18n.t("dates.long_date_without_year_with_linebreak")
: I18n.t("dates.long_no_year_no_time")
);
}
return moment(dt).format(
showTime
? I18n.t("dates.long_date_with_year_with_linebreak")
: I18n.t("dates.long_date_with_year_without_time")
);
}
export default class TopicEntrance extends Component {
@service historyStore;
get createdDate() {
return new Date(this.args.topic.created_at);
}
get bumpedDate() {
return new Date(this.args.topic.bumped_at);
}
get showTime() {
return (
this.bumpedDate.getTime() - this.createdDate.getTime() <
1000 * 60 * 60 * 24 * 2
);
}
get topDate() {
return entranceDate(this.createdDate, this.showTime);
}
get bottomDate() {
return entranceDate(this.bumpedDate, this.showTime);
}
@action
jumpTo(destination) {
this.historyStore.set("lastTopicIdViewed", this.args.topic.id);
DiscourseURL.routeTo(destination);
}
<template>
<DMenu
@ariaLabel={{@title}}
@placement="center"
@autofocus={{true}}
@triggerClass={{@triggerClass}}
>
<:trigger>
{{yield}}
</:trigger>
<:content>
<div id="topic-entrance" class="--glimmer">
<button
{{on "click" (fn this.jumpTo @topic.url)}}
aria-label="topic_entrance.sr_jump_top_button"
class="btn btn-default full jump-top"
>
{{icon "step-backward"}}
{{htmlSafe this.topDate}}
</button>
<button
{{on "click" (fn this.jumpTo @topic.lastPostUrl)}}
aria-label="topic_entrance.sr_jump_bottom_button"
class="btn btn-default full jump-bottom"
>
{{htmlSafe this.bottomDate}}
{{icon "step-forward"}}
</button>
</div>
</:content>
</DMenu>
</template>
}

View File

@ -0,0 +1,16 @@
import dirSpan from "discourse/helpers/dir-span";
import i18n from "discourse-common/helpers/i18n";
const TopicExcerpt = <template>
{{#if @topic.hasExcerpt}}
<a href={{@topic.url}} class="topic-excerpt">
{{dirSpan @topic.escapedExcerpt htmlSafe="true"}}
{{#if @topic.excerptTruncated}}
<span class="topic-excerpt-more">{{i18n "read_more"}}</span>
{{/if}}
</a>
{{/if}}
</template>;
export default TopicExcerpt;

View File

@ -0,0 +1,21 @@
import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template";
export default class TopicLink extends Component {
get url() {
return this.args.topic.linked_post_number
? this.args.topic.urlForPostNumber(this.args.topic.linked_post_number)
: this.args.topic.lastUnreadUrl;
}
<template>
<a
href={{this.url}}
data-topic-id={{@topic.id}}
role="heading"
aria-level="2"
class="title"
...attributes
>{{htmlSafe @topic.fancyTitle}}</a>
</template>
}

View File

@ -0,0 +1,150 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { service } from "@ember/service";
import TopicBulkActions from "discourse/components/modal/topic-bulk-actions";
import NewListHeaderControls from "discourse/components/topic-list/new-list-header-controls";
import TopicBulkSelectDropdown from "discourse/components/topic-list/topic-bulk-select-dropdown";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
export default class TopicListHeaderColumn extends Component {
@service modal;
@service router;
get localizedName() {
if (this.args.forceName) {
return this.args.forceName;
}
return this.args.name ? i18n(this.args.name) : "";
}
get isSorting() {
return this.args.sortable && this.args.order === this.args.activeOrder;
}
get ariaSort() {
if (this.isSorting) {
return this.args.ascending ? "ascending" : "descending";
}
}
// TODO: this code probably shouldn't be in all columns
@action
bulkSelectAll() {
this.args.bulkSelectHelper.autoAddTopicsToBulkSelect = true;
document
.querySelectorAll("input.bulk-select:not(:checked)")
.forEach((el) => el.click());
}
@action
bulkClearAll() {
this.args.bulkSelectHelper.autoAddTopicsToBulkSelect = false;
document
.querySelectorAll("input.bulk-select:checked")
.forEach((el) => el.click());
}
@action
bulkSelectActions() {
this.modal.show(TopicBulkActions, {
model: {
topics: this.args.bulkSelectHelper.selected,
category: this.category,
refreshClosure: () => this.router.refresh(),
},
});
}
@action
onClick() {
this.args.changeSort(this.args.order);
}
@action
onKeyDown(event) {
if (event.key === "Enter" || event.key === " ") {
this.args.changeSort(this.args.order);
event.preventDefault();
}
}
<template>
<th
{{(if @sortable (modifier on "click" this.onClick))}}
{{(if @sortable (modifier on "keydown" this.onKeyDown))}}
data-sort-order={{@order}}
scope="col"
tabindex={{if @sortable "0"}}
role={{if @sortable "button"}}
aria-pressed={{this.isSorting}}
aria-sort={{this.ariaSort}}
class={{concatClass
"topic-list-data"
@order
(if @sortable "sortable")
(if @isSorting "sorting")
(if @number "num")
}}
...attributes
>
{{#if @canBulkSelect}}
{{#if @showBulkToggle}}
<button
{{on "click" @bulkSelectHelper.toggleBulkSelect}}
title={{i18n "topics.bulk.toggle"}}
class="btn-flat bulk-select"
>
{{icon (if @experimentalTopicBulkActionsEnabled "tasks" "list")}}
</button>
{{/if}}
{{#if @bulkSelectEnabled}}
<span class="bulk-select-topics">
{{#if @canDoBulkActions}}
{{#if @experimentalTopicBulkActionsEnabled}}
<TopicBulkSelectDropdown
@bulkSelectHelper={{@bulkSelectHelper}}
/>
{{else}}
<button
{{on "click" this.bulkSelectActions}}
class="btn btn-icon no-text bulk-select-actions"
>{{icon "cog"}}&#8203;</button>
{{/if}}
{{/if}}
<button
{{on "click" this.bulkSelectAll}}
class="btn btn-default bulk-select-all"
>{{i18n "topics.bulk.select_all"}}</button>
<button
{{on "click" this.bulkClearAll}}
class="btn btn-default bulk-clear-all"
>{{i18n "topics.bulk.clear_all"}}</button>
</span>
{{/if}}
{{/if}}
{{#unless @bulkSelectEnabled}}
{{#if this.showTopicsAndRepliesToggle}}
<NewListHeaderControls
@current={{@newListSubset}}
@newRepliesCount={{@newRepliesCount}}
@newTopicsCount={{@newTopicsCount}}
@changeNewListSubset={{@changeNewListSubset}}
/>
{{else}}
<span>{{this.localizedName}}</span>
{{/if}}
{{/unless}}
{{#if this.isSorting}}
{{icon (if @ascending "chevron-up" "chevron-down")}}
{{/if}}
</th>
</template>
}

View File

@ -0,0 +1,118 @@
import { on } from "@ember/modifier";
import PluginOutlet from "discourse/components/plugin-outlet";
import TopicListHeaderColumn from "discourse/components/topic-list/topic-list-header-column";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
const TopicListHeader = <template>
<PluginOutlet @name="topic-list-header-before" />
{{#if @bulkSelectEnabled}}
<th class="bulk-select topic-list-data">
{{#if @canBulkSelect}}
<button
{{on "click" @bulkSelectHelper.toggleBulkSelect}}
title={{i18n "topics.bulk.toggle"}}
class="btn-flat bulk-select"
>
{{icon (if @experimentalTopicBulkActionsEnabled "tasks" "list")}}
</button>
{{/if}}
</th>
{{/if}}
<TopicListHeaderColumn
@order="default"
@category={{@category}}
@activeOrder={{@order}}
@changeSort={{@changeSort}}
@ascending={{@ascending}}
@name={{@listTitle}}
@bulkSelectEnabled={{@bulkSelectEnabled}}
@showBulkToggle={{@toggleInTitle}}
@canBulkSelect={{@canBulkSelect}}
@canDoBulkActions={{@canDoBulkActions}}
@showTopicsAndRepliesToggle={{@showTopicsAndRepliesToggle}}
@newListSubset={{@newListSubset}}
@newRepliesCount={{@newRepliesCount}}
@newTopicsCount={{@newTopicsCount}}
@experimentalTopicBulkActionsEnabled={{@experimentalTopicBulkActionsEnabled}}
@bulkSelectHelper={{@bulkSelectHelper}}
@changeNewListSubset={{@changeNewListSubset}}
/>
<PluginOutlet @name="topic-list-header-after-main-link" />
{{#if @showPosters}}
<TopicListHeaderColumn
@order="posters"
@activeOrder={{@order}}
@changeSort={{@changeSort}}
@ascending={{@ascending}}
aria-label={{i18n "category.sort_options.posters"}}
/>
{{/if}}
<TopicListHeaderColumn
@sortable={{@sortable}}
@number="true"
@order="posts"
@activeOrder={{@order}}
@changeSort={{@changeSort}}
@ascending={{@ascending}}
@name="replies"
aria-label={{i18n "sr_replies"}}
/>
{{#if @showLikes}}
<TopicListHeaderColumn
@sortable={{@sortable}}
@number="true"
@order="likes"
@activeOrder={{@order}}
@changeSort={{@changeSort}}
@ascending={{@ascending}}
@name="likes"
aria-label={{i18n "sr_likes"}}
/>
{{/if}}
{{#if @showOpLikes}}
<TopicListHeaderColumn
@sortable={{@sortable}}
@number="true"
@order="op_likes"
@activeOrder={{@order}}
@changeSort={{@changeSort}}
@ascending={{@ascending}}
@name="likes"
aria-label={{i18n "sr_op_likes"}}
/>
{{/if}}
<TopicListHeaderColumn
@sortable={{@sortable}}
@number="true"
@order="views"
@activeOrder={{@order}}
@changeSort={{@changeSort}}
@ascending={{@ascending}}
@name="views"
aria-label={{i18n "sr_views"}}
/>
<TopicListHeaderColumn
@sortable={{@sortable}}
@number="true"
@order="activity"
@activeOrder={{@order}}
@changeSort={{@changeSort}}
@ascending={{@ascending}}
@name="activity"
aria-label={{i18n "sr_activity"}}
/>
<PluginOutlet @name="topic-list-header-after" />
</template>;
export default TopicListHeader;

View File

@ -0,0 +1,503 @@
import Component from "@glimmer/component";
import { concat, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { service } from "@ember/service";
import { eq, gt } from "truth-helpers";
import PluginOutlet from "discourse/components/plugin-outlet";
import ActionList from "discourse/components/topic-list/action-list";
import ActivityColumn from "discourse/components/topic-list/activity-column";
import ParticipantGroups from "discourse/components/topic-list/participant-groups";
import PostCountOrBadges from "discourse/components/topic-list/post-count-or-badges";
import PostersColumn from "discourse/components/topic-list/posters-column";
import PostsCountColumn from "discourse/components/topic-list/posts-count-column";
import TopicExcerpt from "discourse/components/topic-list/topic-excerpt";
import TopicLink from "discourse/components/topic-list/topic-link";
import UnreadIndicator from "discourse/components/topic-list/unread-indicator";
import TopicPostBadges from "discourse/components/topic-post-badges";
import TopicStatus from "discourse/components/topic-status";
import { topicTitleDecorators } from "discourse/components/topic-title";
import avatar from "discourse/helpers/avatar";
import categoryLink from "discourse/helpers/category-link";
import concatClass from "discourse/helpers/concat-class";
import discourseTags from "discourse/helpers/discourse-tags";
import formatDate from "discourse/helpers/format-date";
import number from "discourse/helpers/number";
import topicFeaturedLink from "discourse/helpers/topic-featured-link";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import DiscourseURL, { groupPath } from "discourse/lib/url";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class TopicListItem extends Component {
@service appEvents;
@service currentUser;
@service historyStore;
@service messageBus;
@service router;
@service site;
@service siteSettings;
constructor() {
super(...arguments);
if (this.includeUnreadIndicator) {
this.messageBus.subscribe(this.unreadIndicatorChannel, this.onMessage);
}
}
willDestroy() {
super.willDestroy(...arguments);
this.messageBus.unsubscribe(this.unreadIndicatorChannel, this.onMessage);
}
@bind
onMessage(data) {
const nodeClassList = document.querySelector(
`.indicator-topic-${data.topic_id}`
).classList;
nodeClassList.toggle("read", !data.show_indicator);
}
get unreadIndicatorChannel() {
return `/private-messages/unread-indicator/${this.args.topic.id}`;
}
get includeUnreadIndicator() {
return typeof this.args.topic.unread_by_group_member !== "undefined";
}
get isSelected() {
return this.args.selected?.includes(this.args.topic);
}
get participantGroups() {
if (!this.args.topic.participant_groups) {
return [];
}
return this.args.topic.participant_groups.map((name) => ({
name,
url: groupPath(name),
}));
}
get newDotText() {
return this.currentUser?.trust_level > 0
? ""
: I18n.t("filters.new.lower_title");
}
get tagClassNames() {
return this.args.topic.tags?.map((tagName) => `tag-${tagName}`);
}
get expandPinned() {
if (
!this.args.topic.pinned ||
(this.site.mobileView && !this.siteSettings.show_pinned_excerpt_mobile) ||
(this.site.desktopView && !this.siteSettings.show_pinned_excerpt_desktop)
) {
return false;
}
return (
(this.args.expandGloballyPinned && this.args.topic.pinned_globally) ||
this.args.expandAllPinned
);
}
get shouldFocusLastVisited() {
return this.site.desktopView && this.args.focusLastVisitedTopic;
}
get unreadClass() {
return this.args.topic.unread_by_group_member ? "" : "read";
}
navigateToTopic(topic, href) {
this.historyStore.set("lastTopicIdViewed", topic.id);
DiscourseURL.routeTo(href || topic.url);
}
highlight(element, isLastViewedTopic) {
element.classList.add("highlighted");
element.setAttribute("data-islastviewedtopic", isLastViewedTopic);
element.addEventListener(
"animationend",
() => element.classList.remove("highlighted"),
{ once: true }
);
if (isLastViewedTopic && this.shouldFocusLastVisited) {
element.querySelector(".main-link .title")?.focus();
}
}
@action
highlightIfNeeded(element) {
if (this.args.topic.id === this.historyStore.get("lastTopicIdViewed")) {
this.historyStore.delete("lastTopicIdViewed");
this.highlight(element, true);
} else if (this.args.topic.highlight) {
// highlight new topics that have been loaded from the server or the one we just created
this.args.topic.set("highlight", false);
this.highlight(element, false);
}
}
@action
onTitleFocus(event) {
event.target.classList.add("selected");
}
@action
onTitleBlur(event) {
event.target.classList.remove("selected");
}
@action
applyTitleDecorators(element) {
const rawTopicLink = element.querySelector(".raw-topic-link");
if (rawTopicLink) {
topicTitleDecorators?.forEach((cb) =>
cb(this.args.topic, rawTopicLink, "topic-list-item-title")
);
}
}
@action
onBulkSelectToggle(e) {
if (e.target.checked) {
this.args.selected.addObject(this.args.topic);
if (this.args.lastCheckedElementId && e.shiftKey) {
const bulkSelects = Array.from(
document.querySelectorAll("input.bulk-select")
);
const from = bulkSelects.indexOf(e.target);
const to = bulkSelects.findIndex(
(el) => el.id === this.args.lastCheckedElementId
);
const start = Math.min(from, to);
const end = Math.max(from, to);
bulkSelects
.slice(start, end)
.filter((el) => !el.checked)
.forEach((checkbox) => checkbox.click());
}
this.args.updateLastCheckedElementId(e.target.id);
} else {
this.args.selected.removeObject(this.args.topic);
this.args.updateLastCheckedElementId(null);
}
}
@action
click(e) {
if (
e.target.classList.contains("raw-topic-link") ||
e.target.classList.contains("post-activity")
) {
if (wantsNewWindow(e)) {
return;
}
e.preventDefault();
this.navigateToTopic(this.args.topic, e.target.href);
return;
}
// make full row click target on mobile, due to size constraints
if (
this.site.mobileView &&
e.target.matches(
".topic-list-data, .main-link, .right, .topic-item-stats, .topic-item-stats__category-tags, .discourse-tags"
)
) {
if (wantsNewWindow(e)) {
return;
}
e.preventDefault();
this.navigateToTopic(this.args.topic, this.args.topic.lastUnreadUrl);
return;
}
if (
e.target.classList.contains("d-icon-thumbtack") &&
e.target.closest("a.topic-status")
) {
e.preventDefault();
this.args.topic.togglePinnedForUser();
return;
}
}
@action
keyDown(e) {
if (e.key === "Enter" && e.target.classList.contains("post-activity")) {
e.preventDefault();
this.navigateToTopic(this.args.topic, e.target.href);
}
}
<template>
<tr
{{! template-lint-disable no-invalid-interactive }}
{{didInsert this.applyTitleDecorators}}
{{didInsert this.highlightIfNeeded}}
{{on "keydown" this.keyDown}}
{{on "click" this.click}}
data-topic-id={{@topic.id}}
role={{this.role}}
aria-level={{this.ariaLevel}}
class={{concatClass
"topic-list-item"
(if @topic.category (concat "category-" @topic.category.fullSlug))
(if (eq @topic @lastVisitedTopic) "last-visit")
(if @topic.visited "visited")
(if @topic.hasExcerpt "has-excerpt")
(if @topic.unseen "unseen-topic")
(if @topic.unread_posts "unread-posts")
(if @topic.liked "liked")
(if @topic.archived "archived")
(if @topic.bookmarked "bookmarked")
(if @topic.pinned "pinned")
(if @topic.closed "closed")
this.tagClassNames
}}
>
<PluginOutlet
@name="above-topic-list-item"
@outletArgs={{hash topic=@topic}}
/>
{{#if this.site.desktopView}}
<PluginOutlet @name="topic-list-before-columns" />
{{#if @bulkSelectEnabled}}
<td class="bulk-select topic-list-data">
<label for="bulk-select-{{@topic.id}}">
<input
{{on "click" this.onBulkSelectToggle}}
checked={{this.isSelected}}
type="checkbox"
id="bulk-select-{{@topic.id}}"
class="bulk-select"
/>
</label>
</td>
{{/if}}
<td class="main-link clearfix topic-list-data" colspan="1">
<PluginOutlet @name="topic-list-before-link" />
<span class="link-top-line">{{! no whitespace
}}<PluginOutlet
@name="topic-list-before-status"
/>{{! no whitespace
}}<TopicStatus
@topic={{@topic}}
/>{{! no whitespace
}}<TopicLink
{{on "focus" this.onTitleFocus}}
{{on "blur" this.onTitleBlur}}
@topic={{@topic}}
class="raw-link raw-topic-link"
/>
{{~#if @topic.featured_link~}}
&nbsp;
{{~topicFeaturedLink @topic}}
{{~/if~}}
<PluginOutlet
@name="topic-list-after-title"
/>{{! no whitespace
}}
<UnreadIndicator
@includeUnreadIndicator={{this.includeUnreadIndicator}}
@topicId={{@topic.id}}
class={{this.unreadClass}}
/>
{{~#if @showTopicPostBadges~}}
<TopicPostBadges
@unreadPosts={{@topic.unread_posts}}
@unseen={{@topic.unseen}}
@newDotText={{this.newDotText}}
@url={{@topic.lastUnreadUrl}}
/>
{{~/if~}}
</span>
<div class="link-bottom-line">
{{#unless @hideCategory}}
{{#unless @topic.isPinnedUncategorized}}
<PluginOutlet @name="topic-list-before-category" />
{{categoryLink @topic.category}}
{{/unless}}
{{/unless}}
{{discourseTags @topic mode="list" tagsForUser=@tagsForUser}}
{{#if this.participantGroups}}
<ParticipantGroups @groups={{this.participantGroups}} />
{{/if}}
<ActionList
@topic={{@topic}}
@postNumbers={{@topic.liked_post_numbers}}
@icon="heart"
class="likes"
/>
</div>
{{#if this.expandPinned}}
<TopicExcerpt @topic={{@topic}} />
{{/if}}
<PluginOutlet @name="topic-list-main-link-bottom" />
</td>
<PluginOutlet @name="topic-list-after-main-link" />
{{#if @showPosters}}
<PostersColumn @posters={{@topic.featuredUsers}} />
{{/if}}
<PostsCountColumn @topic={{@topic}} />
{{#if @showLikes}}
<td class="num likes topic-list-data">
{{#if (gt @topic.like_count 0)}}
<a href={{@topic.summaryUrl}}>
{{number @topic.like_count}}
{{icon "heart"}}
</a>
{{/if}}
</td>
{{/if}}
{{#if @showOpLikes}}
<td class="num likes">
{{#if (gt @topic.op_like_count 0)}}
<a href={{@topic.summaryUrl}}>
{{number @topic.op_like_count}}
{{icon "heart"}}
</a>
{{/if}}
</td>
{{/if}}
<td class={{concatClass "num views topic-list-data" @topic.viewsHeat}}>
<PluginOutlet @name="topic-list-before-view-count" />
{{number @topic.views numberKey="views_long"}}
</td>
<ActivityColumn @topic={{@topic}} class="num topic-list-data" />
<PluginOutlet @name="topic-list-after-columns" />
{{else}}
<td class="topic-list-data">
<PluginOutlet @name="topic-list-before-columns" />
<div class="pull-left">
{{#if @bulkSelectEnabled}}
<label for="bulk-select-{{@topic.id}}">
<input
type="checkbox"
id="bulk-select-{{@topic.id}}"
class="bulk-select"
/>
</label>
{{else}}
<a
href={{@topic.lastPostUrl}}
aria-label={{i18n
"latest_poster_link"
username=@topic.lastPosterUser.username
}}
data-user-card={{@topic.lastPosterUser.username}}
>{{avatar @topic.lastPosterUser imageSize="large"}}</a>
{{/if}}
</div>
<div
class="topic-item-metadata right"
>{{! no whitespace
}}<PluginOutlet
@name="topic-list-before-link"
/>
<div
class="main-link"
>{{! no whitespace
}}<PluginOutlet
@name="topic-list-before-status"
/>{{! no whitespace
}}<TopicStatus
@topic={{@topic}}
/>{{! no whitespace
}}<TopicLink
{{on "focus" this.onTitleFocus}}
{{on "blur" this.onTitleBlur}}
@topic={{@topic}}
class="raw-link raw-topic-link"
/>
{{~#if @topic.featured_link~}}
{{topicFeaturedLink @topic}}
{{~/if~}}
<PluginOutlet @name="topic-list-after-title" />
{{~#if @topic.unseen~}}
<span class="topic-post-badges">&nbsp;<span
class="badge-notification new-topic"
></span></span>
{{~/if~}}
{{~#if this.expandPinned~}}
<TopicExcerpt @topic={{@topic}} />
{{~/if~}}
<PluginOutlet @name="topic-list-main-link-bottom" />
</div>{{! no whitespace
}}<PluginOutlet
@name="topic-list-after-main-link"
/>
<div class="pull-right">
<PostCountOrBadges
@topic={{@topic}}
@postBadgesEnabled={{@showTopicPostBadges}}
/>
</div>
<div class="topic-item-stats clearfix">
<span class="topic-item-stats__category-tags">
{{#unless @hideCategory}}
<PluginOutlet @name="topic-list-before-category" />
{{categoryLink @topic.category}}
{{/unless}}
{{discourseTags @topic mode="list"}}
</span>
<div class="num activity last">
<span title={{@topic.bumpedAtTitle}} class="age activity">
<a href={{@topic.lastPostUrl}}>{{formatDate
@topic.bumpedAt
format="tiny"
noTitle="true"
}}</a>
</span>
</div>
</div>
</div>
</td>
{{/if}}
</tr>
</template>
}

View File

@ -0,0 +1,21 @@
import { concat } from "@ember/helper";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
const UnreadIndicator = <template>
{{#if @includeUnreadIndicator~}}
&nbsp;<span
title={{i18n "topic.unread_indicator"}}
class={{concatClass
"badge badge-notification unread-indicator"
(concat "indicator-topic-" @topicId)
}}
...attributes
>
{{~icon "asterisk"~}}
</span>
{{~/if}}
</template>;
export default UnreadIndicator;

View File

@ -3,24 +3,37 @@
padding: 5px;
background: var(--secondary);
box-shadow: var(--shadow-card);
z-index: z("dropdown");
position: absolute;
width: 133px;
@include unselectable;
&:not(.--glimmer) {
z-index: z("dropdown");
position: absolute;
button.full .d-icon {
display: block;
width: 100%;
}
}
&.--glimmer {
button.full {
display: flex;
flex-direction: column;
}
}
button.full {
width: 100%;
margin-bottom: 5px;
flex-wrap: wrap;
.d-icon {
display: block;
margin: 2px auto;
width: 100%;
transform: rotate(90deg);
}
}
button.btn.jump-bottom {
margin: 5px 0 0 0;
}

View File

@ -85,6 +85,12 @@
.title {
margin-right: 5px;
}
&.--glimmer button.-trigger {
background: transparent;
border: none;
padding: 0;
}
}
tbody {

View File

@ -74,7 +74,8 @@ class CurrentUserSerializer < BasicUserSerializer
:new_new_view_enabled?,
:use_experimental_topic_bulk_actions?,
:use_admin_sidebar,
:can_view_raw_email
:can_view_raw_email,
:use_glimmer_topic_list?
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
@ -314,4 +315,8 @@ class CurrentUserSerializer < BasicUserSerializer
def can_view_raw_email
scope.user.in_any_groups?(SiteSetting.view_raw_email_allowed_groups_map)
end
def use_glimmer_topic_list?
scope.user.in_any_groups?(SiteSetting.experimental_glimmer_topic_list_groups_map)
end
end

View File

@ -2647,6 +2647,7 @@ en:
enable_experimental_lightbox: "EXPERIMENTAL: Replace the default image lightbox with the revamped design."
enable_experimental_bookmark_redesign_groups: "EXPERIMENTAL: Show a quick access menu for bookmarks on posts and a new redesigned modal"
glimmer_header_mode: "Control whether the new 'glimmer' header implementation is used. Defaults to 'auto', which will enable automatically once all your themes and plugins are ready. https://meta.discourse.org/t/296544"
experimental_glimmer_topic_list_groups: "EXPERIMENTAL: Enable the new 'glimmer' topic list implementation. This implementation is under active development, and is not intended for production use. Do not develop themes/plugins against it until the implementation is finalized and announced."
experimental_form_templates: "EXPERIMENTAL: Enable the form templates feature. <b>After enabled,</b> manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
admin_sidebar_enabled_groups: "EXPERIMENTAL: Enable sidebar navigation for the admin UI for the specified groups, which replaces the top-level admin navigation buttons."
lazy_load_categories_groups: "EXPERIMENTAL: Lazy load category information only for users of these groups. This improves performance on sites with many categories."

View File

@ -2352,6 +2352,13 @@ developer:
default: ""
allow_any: false
refresh: true
experimental_glimmer_topic_list_groups:
client: true
type: group_list
list_type: compact
default: ""
allow_any: false
refresh: true
enable_experimental_lightbox:
default: false
client: true

View File

@ -31,5 +31,9 @@
</StyleguideExample>
<StyleguideExample @title="<TopicListItem> - latest" class="half-size">
<LatestTopicListItem @topic={{@dummy.topic}} />
{{#if this.currentUser.use_glimmer_topic_list}}
<TopicList::LatestTopicListItem @topic={{@dummy.topic}} />
{{else}}
<LatestTopicListItem @topic={{@dummy.topic}} />
{{/if}}
</StyleguideExample>

View File

@ -3,6 +3,8 @@
module PageObjects
module Components
class CategoryList < PageObjects::Components::Base
TOPIC_LIST_ITEM_SELECTOR = ".category-list.with-topics .featured-topic"
def has_category?(category)
page.has_css?("tr[data-category-id='#{category.id}']")
end
@ -30,6 +32,10 @@ module PageObjects
def click_topic(topic)
page.find("a", text: topic.title).click
end
def topic_list_item_class(topic)
"#{TOPIC_LIST_ITEM_SELECTOR}[data-topic-id='#{topic.id}']"
end
end
end
end

View File

@ -228,6 +228,10 @@ module PageObjects
post_by_number(post).has_css?(".read-state.read", visible: :all, wait: 3)
end
def has_suggested_topic?(topic)
page.has_css?("#suggested-topics .topic-list-item[data-topic-id='#{topic.id}']")
end
def move_to_public_category(category)
click_admin_menu_button
find(".topic-admin-menu-content li.topic-admin-convert").click

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
describe "glimmer topic list", type: :system do
fab!(:user)
before do
SiteSetting.experimental_glimmer_topic_list_groups = "1"
sign_in(user)
end
describe "/latest" do
let(:topic_list) { PageObjects::Components::TopicList.new }
it "shows the list" do
Fabricate.times(5, :topic)
visit("/latest")
expect(topic_list).to have_topics(count: 5)
end
end
describe "categories-with-featured-topics page" do
let(:category_list) { PageObjects::Components::CategoryList.new }
it "shows the list" do
SiteSetting.desktop_category_page_style = "categories_with_featured_topics"
category = Fabricate(:category)
topic = Fabricate(:topic, category: category)
topic2 = Fabricate(:topic)
CategoryFeaturedTopic.feature_topics
visit("/categories")
expect(category_list).to have_topic(topic)
expect(category_list).to have_topic(topic2)
end
end
describe "suggested topics" do
let(:topic_page) { PageObjects::Pages::Topic.new }
it "shows the list" do
topic = Fabricate(:post).topic
topic2 = Fabricate(:post).topic
visit(topic.relative_url)
expect(topic_page).to have_suggested_topic(topic2)
end
end
end