diff --git a/app/assets/javascripts/discourse/app/components/glimmer-topic-timeline.hbs b/app/assets/javascripts/discourse/app/components/glimmer-topic-timeline.hbs
new file mode 100644
index 00000000000..d7243af8a54
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-topic-timeline.hbs
@@ -0,0 +1,72 @@
+
diff --git a/app/assets/javascripts/discourse/app/components/glimmer-topic-timeline.js b/app/assets/javascripts/discourse/app/components/glimmer-topic-timeline.js
new file mode 100644
index 00000000000..814fb951cc1
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/glimmer-topic-timeline.js
@@ -0,0 +1,96 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import optionalService from "discourse/lib/optional-service";
+import { inject as service } from "@ember/service";
+
+export default class GlimmerTopicTimeline extends Component {
+ @service site;
+ @service siteSettings;
+ @service currentUser;
+
+ @tracked dockAt = null;
+ @tracked dockBottom = null;
+ @tracked enteredIndex = this.args.enteredIndex;
+
+ adminTools = optionalService();
+ intersectionObserver = null;
+
+ constructor() {
+ super(...arguments);
+
+ if (this.args.prevEvent) {
+ this.enteredIndex = this.args.prevEvent.postIndex - 1;
+ }
+
+ if (!this.site.mobileView) {
+ this.intersectionObserver = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ const bounds = entry.boundingClientRect;
+
+ if (entry.target.id === "topic-bottom") {
+ this.topicBottom = bounds.y + window.scrollY;
+ } else {
+ this.topicTop = bounds.y + window.scrollY;
+ }
+ }
+ });
+
+ const elements = [
+ document.querySelector(".container.posts"),
+ document.querySelector("#topic-bottom"),
+ ];
+
+ for (let i = 0; i < elements.length; i++) {
+ this.intersectionObserver.observe(elements[i]);
+ }
+ }
+ }
+
+ get displaySummary() {
+ return (
+ this.siteSettings.summary_timeline_button &&
+ !this.args.fullScreen &&
+ this.args.model.has_summary &&
+ !this.args.model.postStream.summary
+ );
+ }
+
+ get class() {
+ const classes = [];
+ if (this.args.fullscreen) {
+ if (this.addShowClass) {
+ classes.push("timeline-fullscreen show");
+ } else {
+ classes.push("timeline-fullscreen");
+ }
+ }
+
+ if (this.dockAt) {
+ classes.push("timeline-docked");
+ if (this.dockBottom) {
+ classes.push("timeline-docked-bottom");
+ }
+ }
+
+ return classes.join(" ");
+ }
+
+ get addShowClass() {
+ return this.args.fullscreen && !this.args.addShowClass;
+ }
+
+ get canCreatePost() {
+ return this.args.model.details?.can_create_post;
+ }
+
+ get createdAt() {
+ return new Date(this.args.model.created_at);
+ }
+
+ willDestroy() {
+ if (!this.site.mobileView) {
+ this.intersectionObserver?.disconnect();
+ this.intersectionObserver = null;
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/topic-timeline/back-button.hbs b/app/assets/javascripts/discourse/app/components/topic-timeline/back-button.hbs
new file mode 100644
index 00000000000..ad1cd7410e2
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/topic-timeline/back-button.hbs
@@ -0,0 +1,8 @@
+
+ {{i18n "topic.timeline.back"}}
+
diff --git a/app/assets/javascripts/discourse/app/components/topic-timeline/container.hbs b/app/assets/javascripts/discourse/app/components/topic-timeline/container.hbs
new file mode 100644
index 00000000000..93fce8a0916
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/topic-timeline/container.hbs
@@ -0,0 +1,97 @@
+{{#if @fullscreen}}
+
+
+ {{#if (or this.siteSettings.topic_featured_link_enabled this.showTags)}}
+
+ {{/if}}
+
+ {{#if (and (not @model.isPrivateMessage) @model.category)}}
+
+ {{#if @model.category.parentCategory}}
+ {{category-link @model.category.parentCategory}}
+ {{/if}}
+ {{category-link @model.category}}
+
+ {{/if}}
+ {{#if this.excerpt}}
+
{{html-safe this.excerpt}}
+ {{/if}}
+
+{{/if}}
+
+{{#if (and (not @fullscreen) @currentUser)}}
+
+
+
+{{/if}}
+
+{{#if this.displayTimeLineScrollArea}}
+
+{{/if}}
diff --git a/app/assets/javascripts/discourse/app/components/topic-timeline/container.js b/app/assets/javascripts/discourse/app/components/topic-timeline/container.js
new file mode 100644
index 00000000000..9442219d485
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/topic-timeline/container.js
@@ -0,0 +1,335 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import { relativeAge } from "discourse/lib/formatter";
+import I18n from "I18n";
+import { htmlSafe } from "@ember/template";
+import { inject as service } from "@ember/service";
+import { bind, debounce } from "discourse-common/utils/decorators";
+import { actionDescriptionHtml } from "discourse/widgets/post-small-action";
+import domUtils from "discourse-common/utils/dom-utils";
+
+export const SCROLLER_HEIGHT = 50;
+const MIN_SCROLLAREA_HEIGHT = 170;
+const MAX_SCROLLAREA_HEIGHT = 300;
+const LAST_READ_HEIGHT = 20;
+
+export default class TopicTimelineScrollArea extends Component {
+ @service appEvents;
+ @service siteSettings;
+
+ @tracked showButton = false;
+ @tracked current;
+ @tracked percentage = this._percentFor(
+ this.args.model,
+ this.args.enteredIndex
+ );
+ @tracked total;
+ @tracked date;
+ @tracked lastReadPercentage = null;
+ @tracked displayTimeLineScrollArea = true;
+ @tracked before;
+ @tracked after;
+ @tracked timelineScrollareaStyle;
+ @tracked dragging = false;
+ @tracked excerpt = "";
+
+ constructor() {
+ super(...arguments);
+
+ if (!this.args.mobileView) {
+ const streamLength = this.args.model.postStream?.stream?.length;
+
+ if (streamLength === 1) {
+ const postsWrapper = document.querySelector(".posts-wrapper");
+ if (postsWrapper && postsWrapper.offsetHeight < 1000) {
+ this.displayTimeLineScrollArea = false;
+ }
+ }
+
+ // listen for scrolling event to update timeline
+ this.appEvents.on("topic:current-post-scrolled", this.postScrolled);
+ // listen for composer sizing changes to update timeline
+ this.appEvents.on("composer:opened", this.calculatePosition);
+ this.appEvents.on("composer:resized", this.calculatePosition);
+ this.appEvents.on("composer:closed", this.calculatePosition);
+ }
+
+ this.calculatePosition();
+ }
+
+ get showTags() {
+ return (
+ this.siteSettings.tagging_enabled && this.args.model.tags?.length > 0
+ );
+ }
+
+ get style() {
+ return htmlSafe(`height: ${scrollareaHeight()}px`);
+ }
+
+ get beforePadding() {
+ return htmlSafe(`height: ${this.before}px`);
+ }
+
+ get afterPadding() {
+ return htmlSafe(`height: ${this.after}px`);
+ }
+
+ get showDockedButton() {
+ return !this.args.mobileView && this.hasBackPosition && !this.showButton;
+ }
+
+ get hasBackPosition() {
+ return (
+ this.lastRead &&
+ this.lastRead > 3 &&
+ this.lastRead > this.current &&
+ Math.abs(this.lastRead - this.current) > 3 &&
+ Math.abs(this.lastRead - this.total) > 1 &&
+ this.lastRead !== this.total
+ );
+ }
+
+ get lastReadStyle() {
+ return htmlSafe(
+ `height: ${LAST_READ_HEIGHT}px; top: ${this.topPosition}px`
+ );
+ }
+
+ get topPosition() {
+ const bottom = scrollareaHeight() - LAST_READ_HEIGHT / 2;
+ return this.lastReadTop > bottom ? bottom : this.lastReadTop;
+ }
+
+ get bottomAge() {
+ return relativeAge(
+ new Date(this.args.model.last_posted_at || this.args.model.created_at),
+ {
+ addAgo: true,
+ defaultFormat: timelineDate,
+ }
+ );
+ }
+
+ get startDate() {
+ return timelineDate(this.args.model.createdAt);
+ }
+
+ get nowDate() {
+ return this.bottomAge;
+ }
+
+ get lastReadHeight() {
+ return Math.round(this.lastReadPercentage * scrollareaHeight());
+ }
+
+ @bind
+ calculatePosition() {
+ this.timelineScrollareaStyle = htmlSafe(`height: ${scrollareaHeight()}px`);
+
+ const topic = this.args.model;
+ const postStream = topic.postStream;
+ this.total = postStream.filteredPostsCount;
+
+ this.scrollPosition =
+ this.clamp(Math.floor(this.total * this.percentage), 0, this.total) + 1;
+
+ this.current = this.clamp(this.scrollPosition, 1, this.total);
+ const daysAgo = postStream.closestDaysAgoFor(this.current);
+
+ let date;
+ if (daysAgo === undefined) {
+ const post = postStream.posts.findBy(
+ "id",
+ postStream.stream[this.current]
+ );
+
+ if (post) {
+ date = new Date(post.created_at);
+ }
+ } else if (daysAgo !== null) {
+ date = new Date();
+ date.setDate(date.getDate() - daysAgo || 0);
+ } else {
+ date = null;
+ }
+
+ this.date = date;
+
+ const lastReadId = topic.last_read_post_id;
+ const lastReadNumber = topic.last_read_post_number;
+
+ if (lastReadId && lastReadNumber) {
+ const idx = postStream.stream.indexOf(lastReadId) + 1;
+ this.lastRead = idx;
+ this.lastReadPercentage = this._percentFor(topic, idx);
+ }
+
+ if (this.position !== this.scrollPosition) {
+ this.position = this.scrollPosition;
+ this.updateScrollPosition(this.current);
+ }
+
+ this.before = this.scrollareaRemaining() * this.percentage;
+ this.after = scrollareaHeight() - this.before - SCROLLER_HEIGHT;
+
+ if (this.percentage === null) {
+ return;
+ }
+
+ if (this.hasBackPosition) {
+ this.lastReadTop = Math.round(
+ this.lastReadPercentage * scrollareaHeight()
+ );
+ this.showButton =
+ this.before + SCROLLER_HEIGHT - 5 < this.lastReadTop ||
+ this.before > this.lastReadTop + 25;
+ }
+
+ if (this.hasBackPosition) {
+ this.lastReadTop = Math.round(
+ this.lastReadPercentage * scrollareaHeight()
+ );
+ }
+ }
+
+ @debounce(50)
+ updateScrollPosition(scrollPosition) {
+ // only ran on mobile
+ if (!this.args.fullscreen) {
+ return;
+ }
+
+ const stream = this.args.model.postStream;
+
+ if (!this.position === scrollPosition) {
+ return;
+ }
+
+ // we have an off by one, stream is zero based,
+ stream.excerpt(scrollPosition - 1).then((info) => {
+ if (info && this.position === scrollPosition) {
+ let excerpt = "";
+ if (info.username) {
+ excerpt = "" + info.username + ": ";
+ }
+ if (info.excerpt) {
+ this.excerpt = excerpt + info.excerpt;
+ } else if (info.action_code) {
+ this.excerpt = `${excerpt} ${actionDescriptionHtml(
+ info.action_code,
+ info.created_at,
+ info.username
+ )}`;
+ }
+ }
+ });
+ }
+
+ @bind
+ updatePercentage(e) {
+ // pageY for mouse and mobile
+ const y = e.pageY || e.touches[0].pageY;
+ const area = document.querySelector(".timeline-scrollarea");
+ const areaTop = domUtils.offset(area).top;
+
+ this.percentage = this.clamp(parseFloat(y - areaTop) / area.offsetHeight);
+ this.commit();
+ }
+
+ @bind
+ didStartDrag() {
+ this.dragging = true;
+ }
+
+ @bind
+ dragMove(e) {
+ this.updatePercentage(e);
+ }
+
+ @bind
+ didEndDrag() {
+ this.dragging = false;
+ this.commit();
+ }
+
+ @bind
+ postScrolled(e) {
+ this.current = e.postIndex;
+ this.percentage = e.percent;
+ this.calculatePosition();
+ }
+
+ @action
+ goBack() {
+ this.args.jumpToIndex(this.lastRead);
+ }
+
+ commit() {
+ this.calculatePosition();
+
+ if (!this.dragging) {
+ if (this.current === this.scrollPosition) {
+ this.args.jumpToIndex(this.current);
+ } else {
+ this.args.jumpEnd();
+ }
+ }
+ }
+
+ clamp(p, min = 0.0, max = 1.0) {
+ return Math.max(Math.min(p, max), min);
+ }
+
+ scrollareaRemaining() {
+ return scrollareaHeight() - SCROLLER_HEIGHT;
+ }
+
+ willDestroy() {
+ if (!this.args.mobileView) {
+ this.appEvents.off("composer:opened", this.calculatePosition);
+ this.appEvents.off("composer:resized", this.calculatePosition);
+ this.appEvents.off("composer:closed", this.calculatePosition);
+ this.appEvents.off("topic:current-post-scrolled", this.postScrolled);
+ }
+ }
+
+ _percentFor(topic, postIndex) {
+ const total = topic.postStream.filteredPostsCount;
+ switch (postIndex) {
+ // if first post, no top padding
+ case 0:
+ return 0;
+ // if last, no bottom padding
+ case total - 1:
+ return 1;
+ // otherwise, calculate
+ default:
+ return this.clamp(parseFloat(postIndex) / total);
+ }
+ }
+}
+
+export function scrollareaHeight() {
+ const composerHeight =
+ document.getElementById("reply-control").offsetHeight || 0,
+ headerHeight = document.querySelector(".d-header")?.offsetHeight || 0;
+
+ // scrollarea takes up about half of the timeline's height
+ const availableHeight =
+ (window.innerHeight - composerHeight - headerHeight) / 2;
+
+ return Math.max(
+ MIN_SCROLLAREA_HEIGHT,
+ Math.min(availableHeight, MAX_SCROLLAREA_HEIGHT)
+ );
+}
+
+export function timelineDate(date) {
+ const fmt =
+ date.getFullYear() === new Date().getFullYear()
+ ? "long_no_year_no_time"
+ : "timeline_date";
+ return moment(date).format(I18n.t(`dates.${fmt}`));
+}
diff --git a/app/assets/javascripts/discourse/app/components/topic-timeline/scroller.hbs b/app/assets/javascripts/discourse/app/components/topic-timeline/scroller.hbs
new file mode 100644
index 00000000000..0b0463184dd
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/topic-timeline/scroller.hbs
@@ -0,0 +1,37 @@
+
diff --git a/app/assets/javascripts/discourse/app/components/topic-timeline/scroller.js b/app/assets/javascripts/discourse/app/components/topic-timeline/scroller.js
new file mode 100644
index 00000000000..5dba236ac7d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/topic-timeline/scroller.js
@@ -0,0 +1,21 @@
+import Component from "@glimmer/component";
+import {
+ SCROLLER_HEIGHT,
+ timelineDate,
+} from "discourse/components/topic-timeline/container";
+import I18n from "I18n";
+import { htmlSafe } from "@ember/template";
+
+export default class TopicTimelineScroller extends Component {
+ style = htmlSafe(`height: ${SCROLLER_HEIGHT}px`);
+
+ get repliesShort() {
+ const current = this.args.current;
+ const total = this.args.total;
+ return I18n.t(`topic.timeline.replies_short`, { current, total });
+ }
+
+ get timelineAgo() {
+ return timelineDate(this.args.date);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/modifiers/draggable.js b/app/assets/javascripts/discourse/app/modifiers/draggable.js
new file mode 100644
index 00000000000..d2d6a8035b1
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/modifiers/draggable.js
@@ -0,0 +1,82 @@
+import Modifier from "ember-modifier";
+import { registerDestructor } from "@ember/destroyable";
+import { bind } from "discourse-common/utils/decorators";
+
+export default class DraggableModifier extends Modifier {
+ hasStarted = false;
+ element;
+
+ constructor(owner, args) {
+ super(owner, args);
+ registerDestructor(this, (instance) => instance.cleanup());
+ }
+
+ modify(el, _, { didStartDrag, didEndDrag, dragMove }) {
+ this.element = el;
+ this.didStartDragCallback = didStartDrag;
+ this.didEndDragCallback = didEndDrag;
+ this.dragMoveCallback = dragMove;
+ this.element.addEventListener("touchstart", this.dragMove, {
+ passive: false,
+ });
+ this.element.addEventListener("mousedown", this.dragMove, {
+ passive: false,
+ });
+ }
+
+ @bind
+ dragMove(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ if (!this.hasStarted) {
+ this.hasStarted = true;
+
+ if (this.didStartDragCallback) {
+ this.didStartDragCallback();
+ }
+
+ // Register a global event to capture mouse moves when element 'clicked'.
+ document.addEventListener("touchmove", this.drag, { passive: false });
+ document.addEventListener("mousemove", this.drag, { passive: false });
+ document.body.classList.add("dragging");
+
+ // On leaving click, stop moving.
+ document.addEventListener("touchend", this.didEndDrag, {
+ passive: false,
+ });
+ document.addEventListener("mouseup", this.didEndDrag, {
+ passive: false,
+ });
+ }
+ }
+
+ @bind
+ drag(e) {
+ if (this.hasStarted && this.dragMoveCallback) {
+ this.dragMoveCallback(e, this.element);
+ }
+ }
+
+ @bind
+ didEndDrag(e) {
+ if (this.hasStarted) {
+ this.didEndDragCallback(e, this.element);
+
+ document.removeEventListener("touchmove", this.drag);
+ document.removeEventListener("mousemove", this.drag);
+
+ document.body.classList.remove("dragging");
+ this.hasStarted = false;
+ }
+ }
+
+ cleanup() {
+ document.removeEventListener("touchstart", this.dragMove);
+ document.removeEventListener("mousedown", this.dragMove);
+ document.removeEventListener("touchend", this.didEndDrag);
+ document.removeEventListener("mouseup", this.didEndDrag);
+ document.removeEventListener("mousemove", this.drag);
+ document.removeEventListener("touchmove", this.drag);
+ document.body.classList.remove("dragging");
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/templates/topic.hbs b/app/assets/javascripts/discourse/app/templates/topic.hbs
index 85186edfe29..b7099d84c13 100644
--- a/app/assets/javascripts/discourse/app/templates/topic.hbs
+++ b/app/assets/javascripts/discourse/app/templates/topic.hbs
@@ -107,7 +107,38 @@
{{#if info.renderTimeline}}
-
+ {{#if this.currentUser.redesigned_topic_timeline_enabled}}
+
+ {{else}}
+
+ {{/if}}
{{else}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/glimmer-topic-timeline-test.js b/app/assets/javascripts/discourse/tests/acceptance/glimmer-topic-timeline-test.js
new file mode 100644
index 00000000000..fc19b1ab83a
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/glimmer-topic-timeline-test.js
@@ -0,0 +1,401 @@
+import { click, currentURL, visit } from "@ember/test-helpers";
+import {
+ acceptance,
+ exists,
+ query,
+} from "discourse/tests/helpers/qunit-helpers";
+import { test } from "qunit";
+
+acceptance("Glimmer Topic Timeline", function (needs) {
+ needs.user({
+ admin: true,
+ redesigned_topic_timeline_enabled: true,
+ });
+ needs.pretender((server, helper) => {
+ server.get("/t/129.json", () => {
+ return helper.response({
+ post_stream: {
+ posts: [
+ {
+ id: 132,
+ name: null,
+ username: "foo",
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/b19c9b/{size}.png",
+ created_at: "2020-07-08T15:03:53.166Z",
+ cooked: "Deleted post
",
+ post_number: 1,
+ post_type: 1,
+ updated_at: "2020-07-08T15:04:33.425Z",
+ reply_count: 0,
+ reply_to_post_number: null,
+ quote_count: 0,
+ incoming_link_count: 0,
+ reads: 1,
+ readers_count: 0,
+ score: 0,
+ yours: true,
+ topic_id: 129,
+ topic_slug: "deleted-topic-with-whisper-post",
+ display_username: null,
+ primary_group_name: null,
+ flair_name: null,
+ flair_url: null,
+ flair_bg_color: null,
+ flair_color: null,
+ version: 1,
+ can_edit: true,
+ can_delete: false,
+ can_recover: true,
+ can_wiki: true,
+ read: true,
+ user_title: null,
+ bookmarked: false,
+ bookmarks: [],
+ actions_summary: [
+ {
+ id: 3,
+ can_act: true,
+ },
+ {
+ id: 4,
+ can_act: true,
+ },
+ {
+ id: 8,
+ can_act: true,
+ },
+ {
+ id: 7,
+ can_act: true,
+ },
+ ],
+ moderator: false,
+ admin: true,
+ staff: true,
+ user_id: 7,
+ hidden: false,
+ trust_level: 4,
+ deleted_at: "2020-07-08T15:04:37.544Z",
+ deleted_by: {
+ id: 7,
+ username: "foo",
+ name: null,
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/b19c9b/{size}.png",
+ },
+ user_deleted: false,
+ edit_reason: null,
+ can_view_edit_history: true,
+ wiki: false,
+ reviewable_id: 0,
+ reviewable_score_count: 0,
+ reviewable_score_pending_count: 0,
+ },
+ {
+ id: 133,
+ name: null,
+ username: "foo",
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/b19c9b/{size}.png",
+ created_at: "2020-07-08T15:04:23.190Z",
+ cooked: "Whisper post
",
+ post_number: 2,
+ post_type: 4,
+ updated_at: "2020-07-08T15:04:23.190Z",
+ reply_count: 0,
+ reply_to_post_number: null,
+ quote_count: 0,
+ incoming_link_count: 0,
+ reads: 1,
+ readers_count: 0,
+ score: 0,
+ yours: true,
+ topic_id: 129,
+ topic_slug: "deleted-topic-with-whisper-post",
+ display_username: null,
+ primary_group_name: null,
+ flair_name: null,
+ flair_url: null,
+ flair_bg_color: null,
+ flair_color: null,
+ version: 1,
+ can_edit: true,
+ can_delete: true,
+ can_recover: false,
+ can_wiki: true,
+ read: true,
+ user_title: null,
+ bookmarked: false,
+ bookmarks: [],
+ actions_summary: [
+ {
+ id: 3,
+ can_act: true,
+ },
+ {
+ id: 4,
+ can_act: true,
+ },
+ {
+ id: 8,
+ can_act: true,
+ },
+ {
+ id: 7,
+ can_act: true,
+ },
+ ],
+ moderator: false,
+ admin: true,
+ staff: true,
+ user_id: 7,
+ hidden: false,
+ trust_level: 4,
+ deleted_at: null,
+ user_deleted: false,
+ edit_reason: null,
+ can_view_edit_history: true,
+ wiki: false,
+ reviewable_id: 0,
+ reviewable_score_count: 0,
+ reviewable_score_pending_count: 0,
+ },
+ ],
+ stream: [132, 133],
+ },
+ timeline_lookup: [[1, 0]],
+ suggested_topics: [
+ {
+ id: 7,
+ title: "Welcome to Discourse",
+ fancy_title: "Welcome to Discourse",
+ slug: "welcome-to-discourse",
+ posts_count: 1,
+ reply_count: 0,
+ highest_post_number: 1,
+ image_url: null,
+ created_at: "2020-07-08T14:56:57.424Z",
+ last_posted_at: "2020-07-08T14:56:57.488Z",
+ bumped: true,
+ bumped_at: "2020-07-08T14:56:57.488Z",
+ archetype: "regular",
+ unseen: false,
+ pinned: true,
+ unpinned: null,
+ excerpt:
+ "The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage. It’s important! \nEdit this into a brief description of your community: \n\nWho is it for?\nWhat can they fi…",
+ visible: true,
+ closed: false,
+ archived: false,
+ bookmarked: null,
+ liked: null,
+ tags: [],
+ like_count: 0,
+ views: 0,
+ category_id: 1,
+ featured_link: null,
+ posters: [
+ {
+ extras: "latest single",
+ description: "Original Poster, Most Recent Poster",
+ user: {
+ id: -1,
+ username: "system",
+ name: "system",
+ avatar_template: "/images/discourse-logo-sketch-small.png",
+ },
+ },
+ ],
+ },
+ ],
+ tags: [],
+ id: 129,
+ title: "Deleted topic with whisper post",
+ fancy_title: "Deleted topic with whisper post",
+ posts_count: 0,
+ created_at: "2020-07-08T15:03:53.045Z",
+ views: 1,
+ reply_count: 0,
+ like_count: 0,
+ last_posted_at: null,
+ visible: true,
+ closed: false,
+ archived: false,
+ has_summary: false,
+ archetype: "regular",
+ slug: "deleted-topic-with-whisper-post",
+ category_id: 1,
+ word_count: 8,
+ deleted_at: "2020-07-08T15:04:37.580Z",
+ user_id: 7,
+ featured_link: null,
+ pinned_globally: false,
+ pinned_at: null,
+ pinned_until: null,
+ image_url: null,
+ slow_mode_seconds: 0,
+ draft: null,
+ draft_key: "topic_129",
+ draft_sequence: 5,
+ posted: true,
+ unpinned: null,
+ pinned: false,
+ current_post_number: 1,
+ highest_post_number: 2,
+ last_read_post_number: 0,
+ bookmarks: [],
+ last_read_post_id: null,
+ deleted_by: {
+ id: 7,
+ username: "foo",
+ name: null,
+ avatar_template: "/letter_avatar_proxy/v4/letter/f/b19c9b/{size}.png",
+ },
+ has_deleted: false,
+ actions_summary: [
+ {
+ id: 4,
+ count: 0,
+ hidden: false,
+ can_act: true,
+ },
+ {
+ id: 8,
+ count: 0,
+ hidden: false,
+ can_act: true,
+ },
+ {
+ id: 7,
+ count: 0,
+ hidden: false,
+ can_act: true,
+ },
+ ],
+ chunk_size: 20,
+ bookmarked: false,
+ bookmarks: [],
+ topic_timer: null,
+ message_bus_last_id: 5,
+ participant_count: 1,
+ show_read_indicator: false,
+ thumbnails: null,
+ slow_mode_enabled_until: null,
+ details: {
+ can_edit: true,
+ notification_level: 3,
+ notifications_reason_id: 1,
+ can_move_posts: true,
+ can_recover: true,
+ can_remove_allowed_users: true,
+ can_invite_to: true,
+ can_invite_via_email: true,
+ can_reply_as_new_topic: true,
+ can_flag_topic: true,
+ can_review_topic: true,
+ can_close_topic: true,
+ can_archive_topic: true,
+ can_split_merge_topic: true,
+ can_edit_staff_notes: true,
+ can_toggle_topic_visibility: true,
+ can_pin_unpin_topic: true,
+ can_moderate_category: true,
+ can_remove_self_id: 7,
+ participants: [
+ {
+ id: 7,
+ username: "foo",
+ name: null,
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/b19c9b/{size}.png",
+ post_count: 1,
+ primary_group_name: null,
+ flair_name: null,
+ flair_url: null,
+ flair_color: null,
+ flair_bg_color: null,
+ admin: true,
+ trust_level: 4,
+ },
+ ],
+ created_by: {
+ id: 7,
+ username: "foo",
+ name: null,
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/b19c9b/{size}.png",
+ },
+ last_poster: {
+ id: 7,
+ username: "foo",
+ name: null,
+ avatar_template:
+ "/letter_avatar_proxy/v4/letter/f/b19c9b/{size}.png",
+ },
+ },
+ });
+ });
+ });
+
+ test("it has a topic admin menu", async function (assert) {
+ await visit("/t/internationalization-localization");
+ assert.ok(
+ exists(".timeline-controls .topic-admin-menu-button"),
+ "admin menu is present"
+ );
+ });
+
+ test("it has a reply-to-post button", async function (assert) {
+ await visit("/t/internationalization-localization");
+ assert.ok(
+ exists(".timeline-footer-controls .reply-to-post"),
+ "reply to post button is present"
+ );
+ });
+
+ test("it has a topic notification button", async function (assert) {
+ await visit("/t/internationalization-localization");
+ assert.ok(
+ exists(".timeline-footer-controls .topic-notifications-button"),
+ "topic notifications button is present"
+ );
+ });
+
+ test("Shows dates of first and last posts", async function (assert) {
+ await visit("/t/deleted-topic-with-whisper-post/129");
+ assert.strictEqual(
+ query(".timeline-date-wrapper .now-date").innerText,
+ "Jul 2020"
+ );
+ });
+
+ test("selecting start-date navigates you to the first post", async function (assert) {
+ await visit("/t/internationalization-localization/280/2");
+ await click(".timeline-date-wrapper .start-date");
+ assert.strictEqual(
+ currentURL(),
+ "/t/internationalization-localization/280/1",
+ "navigates to the first post"
+ );
+ });
+
+ test("selecting now-date navigates you to the last post", async function (assert) {
+ await visit("/t/internationalization-localization/280/1");
+ await click(".timeline-date-wrapper .now-date");
+ assert.strictEqual(
+ currentURL(),
+ "/t/internationalization-localization/280/11",
+ "navigates to the latest post"
+ );
+ });
+
+ test("clicking the timeline padding updates the position", async function (assert) {
+ await visit("/t/internationalization-localization/280/2");
+ await click(".timeline-scrollarea .timeline-padding");
+ assert.notOk(
+ currentURL().includes("/280/2"),
+ "The position of the currently viewed post has been updated from it's initial position"
+ );
+ });
+});
diff --git a/app/assets/stylesheets/common/topic-timeline.scss b/app/assets/stylesheets/common/topic-timeline.scss
index 888330e495b..2e2c8e45fdb 100644
--- a/app/assets/stylesheets/common/topic-timeline.scss
+++ b/app/assets/stylesheets/common/topic-timeline.scss
@@ -234,6 +234,9 @@
.widget-dragging & {
transition: none;
}
+ .dragging & {
+ transition: none;
+ }
}
.timeline-handle {
diff --git a/app/assets/stylesheets/desktop/discourse.scss b/app/assets/stylesheets/desktop/discourse.scss
index 7984aca95fe..32e1fd72a05 100644
--- a/app/assets/stylesheets/desktop/discourse.scss
+++ b/app/assets/stylesheets/desktop/discourse.scss
@@ -12,6 +12,10 @@ body.widget-dragging {
cursor: ns-resize;
}
+body.dragging {
+ cursor: ns-resize;
+}
+
// Common classes
.boxed {
height: 100%;
diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb
index 0cd66b1d657..278f432965c 100644
--- a/app/serializers/current_user_serializer.rb
+++ b/app/serializers/current_user_serializer.rb
@@ -83,7 +83,8 @@ class CurrentUserSerializer < BasicUserSerializer
:grouped_unread_notifications,
:redesigned_user_menu_enabled,
:redesigned_user_page_nav_enabled,
- :sidebar_list_destination
+ :sidebar_list_destination,
+ :redesigned_topic_timeline_enabled
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
@@ -390,4 +391,12 @@ class CurrentUserSerializer < BasicUserSerializer
false
end
end
+
+ def redesigned_topic_timeline_enabled
+ if SiteSetting.enable_experimental_topic_timeline_groups.present?
+ object.in_any_groups?(SiteSetting.enable_experimental_topic_timeline_groups.split("|").map(&:to_i))
+ else
+ false
+ end
+ end
end
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 6591d85b24a..bed0fe448b3 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -2392,6 +2392,7 @@ en:
default_sidebar_categories: "Selected categories will be displayed under Sidebar's Categories section by default."
default_sidebar_tags: "Selected tags will be displayed under Sidebar's Tags section by default."
enable_new_user_profile_nav_groups: "EXPERIMENTAL: Users of the selected groups will be shown the new user profile navigation menu"
+ enable_experimental_topic_timeline_groups: "EXPERIMENTAL: Users of the selected groups will be shown the refactored topic timeline"
errors:
invalid_css_color: "Invalid color. Enter a color name or hex value."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 96edaa9d6df..c9de05c5e89 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -2050,6 +2050,13 @@ developer:
include_associated_account_ids:
default: false
hidden: true
+ enable_experimental_topic_timeline_groups:
+ client: true
+ type: group_list
+ list_type: compact
+ default: ""
+ allow_any: false
+ refresh: true
sidebar:
enable_sidebar: