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 @@ {{#if this.topics}} - + {{#if this.currentUser.use_glimmer_topic_list}} + + {{else}} + + {{/if}} {{else}} {{#unless this.loadingMore}}
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|}} - + {{#if this.currentUser.use_glimmer_topic_list}} + + {{else}} + + {{/if}} {{/each}} +
{{#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}} - + {{#if this.currentUser.use_glimmer_topic_list}} + + {{else}} + + {{/if}} {{/if}} {{#if this.hasTopics}} - + {{#if this.currentUser.use_glimmer_topic_list}} + + {{else}} + + {{/if}} {{/if}} + -
- {{raw - "list/new-list-header-controls" - current=@current - newRepliesCount=@newRepliesCount - newTopicsCount=@newTopicsCount - noStaticLabel=true - }} -
+ {{#if this.currentUser.use_glimmer_topic_list}} +
+ +
+ {{else}} +
+ {{raw + "list/new-list-header-controls" + current=@current + newRepliesCount=@newRepliesCount + newTopicsCount=@newTopicsCount + noStaticLabel=true + }} +
+ {{/if}} } 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}} {{#each this.category.featuredTopics as |t|}} - + {{#if this.currentUser.use_glimmer_topic_list}} + + {{else}} + + {{/if}} {{/each}} {{/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 = ; + +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"); + } + + +} 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 = ; + +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}`); + } + } + + +} 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; + } + + +} 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; + } + } + + +} 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 = ; + +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 = ; + +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 = ; + +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"); + } + + +} 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 = ; + +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); + } + + +} 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 = ; + +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; + } + + +} 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(); + } + } + + +} 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 = ; + +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); + } + } + + +} 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 = ; + +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. After enabled, manage the templates at Customize / Templates." 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 @@ - + {{#if this.currentUser.use_glimmer_topic_list}} + + {{else}} + + {{/if}} \ 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