diff --git a/app/assets/javascripts/discourse/app/components/basic-topic-list.hbs b/app/assets/javascripts/discourse/app/components/basic-topic-list.hbs index 75124291958..67b00b92ab0 100644 --- a/app/assets/javascripts/discourse/app/components/basic-topic-list.hbs +++ b/app/assets/javascripts/discourse/app/components/basic-topic-list.hbs @@ -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"> diff --git a/app/assets/javascripts/discourse/app/components/categories-topic-list.hbs b/app/assets/javascripts/discourse/app/components/categories-topic-list.hbs index 397ab266a1a..6f424eb798b 100644 --- a/app/assets/javascripts/discourse/app/components/categories-topic-list.hbs +++ b/app/assets/javascripts/discourse/app/components/categories-topic-list.hbs @@ -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 diff --git a/app/assets/javascripts/discourse/app/components/discovery/topics.hbs b/app/assets/javascripts/discourse/app/components/discovery/topics.hbs index 9fd91eb2046..2d42462c8d4 100644 --- a/app/assets/javascripts/discourse/app/components/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/app/components/discovery/topics.hbs @@ -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" diff --git a/app/assets/javascripts/discourse/app/components/new-list-header-controls-wrapper.gjs b/app/assets/javascripts/discourse/app/components/new-list-header-controls-wrapper.gjs index 1036e920144..792e8169fb5 100644 --- a/app/assets/javascripts/discourse/app/components/new-list-header-controls-wrapper.gjs +++ b/app/assets/javascripts/discourse/app/components/new-list-header-controls-wrapper.gjs @@ -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> } diff --git a/app/assets/javascripts/discourse/app/components/parent-category-row.hbs b/app/assets/javascripts/discourse/app/components/parent-category-row.hbs index 564e83cca7c..83e3f2b06a9 100644 --- a/app/assets/javascripts/discourse/app/components/parent-category-row.hbs +++ b/app/assets/javascripts/discourse/app/components/parent-category-row.hbs @@ -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}} diff --git a/app/assets/javascripts/discourse/app/components/topic-list.js b/app/assets/javascripts/discourse/app/components/topic-list.js index 9c7c841b86a..1f5432c1f27 100644 --- a/app/assets/javascripts/discourse/app/components/topic-list.js +++ b/app/assets/javascripts/discourse/app/components/topic-list.js @@ -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 ( diff --git a/app/assets/javascripts/discourse/app/components/topic-list/action-list.gjs b/app/assets/javascripts/discourse/app/components/topic-list/action-list.gjs new file mode 100644 index 00000000000..1cfb2225622 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/action-list.gjs @@ -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; diff --git a/app/assets/javascripts/discourse/app/components/topic-list/activity-column.gjs b/app/assets/javascripts/discourse/app/components/topic-list/activity-column.gjs new file mode 100644 index 00000000000..aa7ecf772c9 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/activity-column.gjs @@ -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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/featured-topic.gjs b/app/assets/javascripts/discourse/app/components/topic-list/featured-topic.gjs new file mode 100644 index 00000000000..d5ba17857cd --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/featured-topic.gjs @@ -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; diff --git a/app/assets/javascripts/discourse/app/components/topic-list/latest-topic-list-item.gjs b/app/assets/javascripts/discourse/app/components/topic-list/latest-topic-list-item.gjs new file mode 100644 index 00000000000..c940bd0a958 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/latest-topic-list-item.gjs @@ -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}} + {{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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/list.gjs b/app/assets/javascripts/discourse/app/components/topic-list/list.gjs new file mode 100644 index 00000000000..69e595db553 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/list.gjs @@ -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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/new-list-header-controls.gjs b/app/assets/javascripts/discourse/app/components/topic-list/new-list-header-controls.gjs new file mode 100644 index 00000000000..a60e6907b89 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/new-list-header-controls.gjs @@ -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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/participant-groups.gjs b/app/assets/javascripts/discourse/app/components/topic-list/participant-groups.gjs new file mode 100644 index 00000000000..1d25593a868 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/participant-groups.gjs @@ -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; diff --git a/app/assets/javascripts/discourse/app/components/topic-list/post-count-or-badges.gjs b/app/assets/javascripts/discourse/app/components/topic-list/post-count-or-badges.gjs new file mode 100644 index 00000000000..6a5c265fd91 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/post-count-or-badges.gjs @@ -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; diff --git a/app/assets/javascripts/discourse/app/components/topic-list/posters-column.gjs b/app/assets/javascripts/discourse/app/components/topic-list/posters-column.gjs new file mode 100644 index 00000000000..7b0d42b195e --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/posters-column.gjs @@ -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; diff --git a/app/assets/javascripts/discourse/app/components/topic-list/posts-count-column.gjs b/app/assets/javascripts/discourse/app/components/topic-list/posts-count-column.gjs new file mode 100644 index 00000000000..70fa158f208 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/posts-count-column.gjs @@ -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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/topic-bulk-select-dropdown.gjs b/app/assets/javascripts/discourse/app/components/topic-list/topic-bulk-select-dropdown.gjs new file mode 100644 index 00000000000..735256c7088 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/topic-bulk-select-dropdown.gjs @@ -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; diff --git a/app/assets/javascripts/discourse/app/components/topic-list/topic-entrance.gjs b/app/assets/javascripts/discourse/app/components/topic-list/topic-entrance.gjs new file mode 100644 index 00000000000..19b3a052308 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/topic-entrance.gjs @@ -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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/topic-excerpt.gjs b/app/assets/javascripts/discourse/app/components/topic-list/topic-excerpt.gjs new file mode 100644 index 00000000000..e293822bd91 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/topic-excerpt.gjs @@ -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; diff --git a/app/assets/javascripts/discourse/app/components/topic-list/topic-link.gjs b/app/assets/javascripts/discourse/app/components/topic-list/topic-link.gjs new file mode 100644 index 00000000000..3c8ccfe7dab --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/topic-link.gjs @@ -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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/topic-list-header-column.gjs b/app/assets/javascripts/discourse/app/components/topic-list/topic-list-header-column.gjs new file mode 100644 index 00000000000..41793bdb292 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/topic-list-header-column.gjs @@ -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"}}​</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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/topic-list-header.gjs b/app/assets/javascripts/discourse/app/components/topic-list/topic-list-header.gjs new file mode 100644 index 00000000000..af24b4fefa0 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/topic-list-header.gjs @@ -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; diff --git a/app/assets/javascripts/discourse/app/components/topic-list/topic-list-item.gjs b/app/assets/javascripts/discourse/app/components/topic-list/topic-list-item.gjs new file mode 100644 index 00000000000..8424ea20b7e --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/topic-list-item.gjs @@ -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~}} + + {{~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"> <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> +} diff --git a/app/assets/javascripts/discourse/app/components/topic-list/unread-indicator.gjs b/app/assets/javascripts/discourse/app/components/topic-list/unread-indicator.gjs new file mode 100644 index 00000000000..d18b6563bf8 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/topic-list/unread-indicator.gjs @@ -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~}} + <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; diff --git a/app/assets/stylesheets/common/topic-entrance.scss b/app/assets/stylesheets/common/topic-entrance.scss index 21fee2bc2b7..cedd7c4a1a5 100644 --- a/app/assets/stylesheets/common/topic-entrance.scss +++ b/app/assets/stylesheets/common/topic-entrance.scss @@ -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; } diff --git a/app/assets/stylesheets/desktop/category-list.scss b/app/assets/stylesheets/desktop/category-list.scss index 1356988dbdb..b769fc21e4b 100644 --- a/app/assets/stylesheets/desktop/category-list.scss +++ b/app/assets/stylesheets/desktop/category-list.scss @@ -85,6 +85,12 @@ .title { margin-right: 5px; } + + &.--glimmer button.-trigger { + background: transparent; + border: none; + padding: 0; + } } tbody { diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index aba648259d0..d9e972ddbbf 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -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 diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index dbbc913454f..ff9668126b1 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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." diff --git a/config/site_settings.yml b/config/site_settings.yml index bb06873f237..01b93c1998e 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -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 diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-list-item.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-list-item.hbs index ad8207fab20..3c7c9be2847 100644 --- a/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-list-item.hbs +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/molecules/topic-list-item.hbs @@ -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> \ No newline at end of file diff --git a/spec/system/page_objects/components/category_list.rb b/spec/system/page_objects/components/category_list.rb index 49e000bf7d7..2fc5ce12375 100644 --- a/spec/system/page_objects/components/category_list.rb +++ b/spec/system/page_objects/components/category_list.rb @@ -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 diff --git a/spec/system/page_objects/pages/topic.rb b/spec/system/page_objects/pages/topic.rb index 1e7df6c93a4..2aa42d909b7 100644 --- a/spec/system/page_objects/pages/topic.rb +++ b/spec/system/page_objects/pages/topic.rb @@ -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 diff --git a/spec/system/topic_list/glimmer_spec.rb b/spec/system/topic_list/glimmer_spec.rb new file mode 100644 index 00000000000..09e056af88b --- /dev/null +++ b/spec/system/topic_list/glimmer_spec.rb @@ -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