DEV: Upgrade Topic Timeline to Glimmer (#17377)

In an effort to modernize our codebase to the latest Ember version we have selected the Topic Timeline as a candidate to be refactored. The topic timeline component was originally built with `Widgets` and this PR will upgrade it to `Glimmer Components`. 

The refactored timeline is hidden by default behind a group flag, `SiteSetting.enable_experimental_topic_timeline_groups`. Being part of a group included in this site setting will make the new timeline available for testing.

## Other points of interest

This PR introduces a `Draggable Modifier` available to all components, which will take the place of the existing _drag functionality_ exclusive to widgets. 

It can be included like so:
```
{{draggable didStartDrag=@didStartDrag didEndDrag=@didEndDrag dragMove=@dragMove }}
```
This commit is contained in:
Isaac Janzen 2022-12-01 13:50:44 -06:00 committed by GitHub
parent 3a4ac3a7c0
commit 6ccc0227f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1206 additions and 2 deletions

View File

@ -0,0 +1,72 @@
<div class={{concat "timeline-container " this.class}}>
<div class="topic-timeline">
<TopicTimeline::Container
@model={{@model}}
@enteredIndex={{this.enteredIndex}}
@jumpTop={{@jumpTop}}
@jumpBottom={{@jumpBottom}}
@jumpEnd={{@jumpEnd}}
@jumpToIndex={{@jumpToIndex}}
@fullscreen={{@fullscreen}}
@mobileView={{@mobileView}}
@currentUser={{this.currentUser}}
@toggleMultiSelect={{@toggleMultiSelect}}
@showTopicSlowModeUpdate={{@showTopicSlowModeUpdate}}
@deleteTopic={{@deleteTopic}}
@recoverTopic={{@recoverTopic}}
@toggleClosed={{@toggleClosed}}
@toggleArchived={{@toggleArchived}}
@toggleVisibility={{@toggleVisibility}}
@showTopicTimerModal={{@showTopicTimerModal}}
@showFeatureTopic={{@showFeatureTopic}}
@showChangeTimestamp={{@showChangeTimestamp}}
@resetBumpDate={{@resetBumpDate}}
@convertToPublicTopic={{@convertToPublicTopic}}
@convertToPrivateMessage={{@convertToPrivateMessage}}
/>
<div class="timeline-footer-controls">
{{#if this.displaySummary}}
<button type="button" class="show-summary btn-small" label={{i18n "summary.short_label"}} title={{i18n "summary.short_title"}} {{on "click" @showSummary}}>
{{d-icon "layer-group"}}
</button>
{{/if}}
{{#if (and this.currentUser (not @fullscreen))}}
{{#if this.canCreatePost}}
<button type="button" class="btn btn-default create reply-to-post no-text btn-icon" title={{i18n "topic.reply.help"}} {{on "click" (fn @replyToPost null)}}>
{{d-icon "reply"}}
</button>
{{/if}}
{{/if}}
{{#if @fullscreen}}
<button
type="button"
{{!-- we need to keep this a widget-button to not close the modal when opening form --}}
class="widget-button btn btn-text jump-to-post"
title={{i18n "topic.progress.jump_prompt_long"}}
{{on "click" @jumpToPostPrompt}}
>
<span class="d-button-label">
{{i18n "topic.progress.jump_prompt"}}
</span>
</button>
{{/if}}
{{#if this.currentUser}}
<TopicNotificationsButton
@notificationLevel={{@model.details.notification_level}}
@topic={{@model}}
@showFullTitle={{false}}
@appendReason={{false}}
@placement={{"bottom-end"}}
@showCaret={{false}}
/>
{{#if @mobileView}}
<TopicAdminMenuButton @topic={{@model}} @addKeyboardTargetClass={{true}} @openUpwards={{true}} />
{{/if}}
{{/if}}
</div>
</div>
</div>

View File

@ -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;
}
}
}

View File

@ -0,0 +1,8 @@
<DButton
@type="button"
@class="btn-primary btn-small back-button"
@title={{i18n "topic.timeline.back_description"}}
@onClick={{@onClick}}
>
{{i18n "topic.timeline.back"}}
</DButton>

View File

@ -0,0 +1,97 @@
{{#if @fullscreen}}
<div class="title">
<h2>
<a class="fancy-title" href {{on "click" @jumpTop}}>{{if @mobileView @model.fancyTitle ""}}</a>
</h2>
{{#if (or this.siteSettings.topic_featured_link_enabled this.showTags)}}
<div class="topic-header-extra">
{{#if this.showTags}}
<div class="list-tags">
{{discourse-tags @model mode="list" tags=@model.tags}}
</div>
{{/if}}
{{#if this.siteSettings.topic_featured_link_enabled}}
{{topic-featured-link @model}}
{{/if}}
</div>
{{/if}}
{{#if (and (not @model.isPrivateMessage) @model.category)}}
<div class="topic-category">
{{#if @model.category.parentCategory}}
{{category-link @model.category.parentCategory}}
{{/if}}
{{category-link @model.category}}
</div>
{{/if}}
{{#if this.excerpt}}
<div class="post-excerpt">{{html-safe this.excerpt}}</div>
{{/if}}
</div>
{{/if}}
{{#if (and (not @fullscreen) @currentUser)}}
<div class="timeline-controls">
<TopicAdminMenuButton
@topic={{@model}}
@addKeyboardTargetClass={{true}}
@toggleMultiSelect={{@toggleMultiSelect}}
@showTopicSlowModeUpdate={{@showTopicSlowModeUpdate}}
@deleteTopic={{@deleteTopic}}
@recoverTopic={{@recoverTopic}}
@toggleClosed={{@toggleClosed}}
@toggleArchived={{@toggleArchived}}
@toggleVisibility={{@toggleVisibility}}
@showTopicTimerModal={{@showTopicTimerModal}}
@showFeatureTopic={{@showFeatureTopic}}
@showChangeTimestamp={{@showChangeTimestamp}}
@resetBumpDate={{@resetBumpDate}}
@convertToPublicTopic={{@convertToPublicTopic}}
@convertToPrivateMessage={{@convertToPrivateMessage}}
/>
</div>
{{/if}}
{{#if this.displayTimeLineScrollArea}}
<div class="timeline-scrollarea-wrapper">
<div class="timeline-date-wrapper">
<a class="start-date" onClick={{this.updatePercentage}} title={{this.startDate}}>
<span>
{{this.startDate}}
</span>
</a>
</div>
<div class="timeline-scrollarea" style={{this.timelineScrollareaStyle}}>
<div class="timeline-padding" style={{this.beforePadding}} onClick={{this.updatePercentage}}></div>
<TopicTimeline::Scroller
@current={{this.current}}
@total={{this.total}}
@goBack={{this.goBack}}
@fullscreen={{@fullscreen}}
@showDockedButton={{this.showDockedButton}}
@date={{this.date}}
@didStartDrag={{this.didStartDrag}}
@dragMove={{this.dragMove}}
@didEndDrag={{this.didEndDrag}}
/>
<div class="timeline-padding" style={{this.afterPadding}} onClick={{this.updatePercentage}}></div>
{{#if this.hasBackPosition}}
<div class="timeline-last-read" style={{this.lastReadStyle}}>
{{d-icon "minus" class="progress"}}
{{#if this.showButton}}
<TopicTimeline::BackButton @onClick={{this.goBack}}/>
{{/if}}
</div>
{{/if}}
</div>
<div class="timeline-date-wrapper">
<a class="now-date" onClick={{this.updatePercentage}} title={{this.nowDate}}>
<span>
{{this.nowDate}}
</span>
</a>
</div>
</div>
{{/if}}

View File

@ -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 = "<span class='username'>" + info.username + ":</span> ";
}
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}`));
}

View File

@ -0,0 +1,37 @@
<div
style={{this.style}}
class="timeline-scroller"
{{draggable didStartDrag=@didStartDrag didEndDrag=@didEndDrag dragMove=@dragMove }}
>
{{#if @fullscreen}}
<div class="timeline-scroller-content">
<div class="timeline-replies">
{{this.repliesShort}}
</div>
{{#if @date}}
<div class="timeline-ago">
{{this.timelineAgo}}
</div>
{{/if}}
{{#if (and @showDockedButton (not @dragging)) }}
<TopicTimeline::BackButton @onClick={{@goBack}}/>
{{/if}}
</div>
<div class="timeline-handle" />
{{else}}
<div class="timeline-handle" />
<div class="timeline-scroller-content">
<div class="timeline-replies">
{{this.repliesShort}}
</div>
{{#if @date}}
<div class="timeline-ago">
{{this.timelineAgo}}
</div>
{{/if}}
{{#if (and @showDockedButton (not @dragging)) }}
<TopicTimeline::BackButton @onClick={{@goBack}}/>
{{/if}}
</div>
{{/if}}
</div>

View File

@ -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);
}
}

View File

@ -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");
}
}

View File

@ -107,7 +107,38 @@
<PluginOutlet @name="topic-navigation" @connectorTagName="div" @args={{hash topic=this.model}} />
{{#if info.renderTimeline}}
<TopicTimeline @topic={{this.model}} @notificationLevel={{this.model.details.notification_level}} @prevEvent={{info.prevEvent}} @fullscreen={{info.topicProgressExpanded}} @enteredIndex={{this.enteredIndex}} @loading={{this.model.postStream.loading}} @jumpToPost={{action "jumpToPost"}} @jumpTop={{action "jumpTop"}} @jumpBottom={{action "jumpBottom"}} @jumpEnd={{action "jumpEnd"}} @jumpToPostPrompt={{action "jumpToPostPrompt"}} @jumpToIndex={{action "jumpToIndex"}} @replyToPost={{action "replyToPost"}} @showSummary={{action "showSummary"}} @toggleMultiSelect={{action "toggleMultiSelect"}} @showTopicSlowModeUpdate={{route-action "showTopicSlowModeUpdate"}} @deleteTopic={{action "deleteTopic"}} @recoverTopic={{action "recoverTopic"}} @toggleClosed={{action "toggleClosed"}} @toggleArchived={{action "toggleArchived"}} @toggleVisibility={{action "toggleVisibility"}} @showTopicTimerModal={{route-action "showTopicTimerModal"}} @showFeatureTopic={{route-action "showFeatureTopic"}} @showChangeTimestamp={{route-action "showChangeTimestamp"}} @resetBumpDate={{action "resetBumpDate"}} @convertToPublicTopic={{action "convertToPublicTopic"}} @convertToPrivateMessage={{action "convertToPrivateMessage"}} />
{{#if this.currentUser.redesigned_topic_timeline_enabled}}
<GlimmerTopicTimeline
@info={{info}}
@model={{this.model}}
@replyToPost={{action "replyToPost"}}
@showSummary={{action "showSummary"}}
@jumpToPostPrompt={{action "jumpToPostPrompt"}}
@enteredIndex={{this.enteredIndex}}
@prevEvent={{info.prevEvent}}
@jumpTop={{action "jumpTop"}}
@jumpBottom={{action "jumpBottom"}}
@jumpEnd={{action "jumpEnd"}}
@jumpToIndex={{action "jumpToIndex"}}
@toggleMultiSelect={{action "toggleMultiSelect"}}
@showTopicSlowModeUpdate={{route-action "showTopicSlowModeUpdate"}}
@deleteTopic={{action "deleteTopic"}}
@recoverTopic={{action "recoverTopic"}}
@toggleClosed={{action "toggleClosed"}}
@toggleArchived={{action "toggleArchived"}}
@toggleVisibility={{action "toggleVisibility"}}
@showTopicTimerModal={{route-action "showTopicTimerModal"}}
@showFeatureTopic={{route-action "showFeatureTopic"}}
@showChangeTimestamp={{route-action "showChangeTimestamp"}}
@resetBumpDate={{action "resetBumpDate"}}
@convertToPublicTopic={{action "convertToPublicTopic"}}
@convertToPrivateMessage={{action "convertToPrivateMessage"}}
@fullscreen={{info.topicProgressExpanded}}
@mobileView={{this.site.mobileView}}
/>
{{else}}
<TopicTimeline @topic={{this.model}} @notificationLevel={{this.model.details.notification_level}} @prevEvent={{info.prevEvent}} @fullscreen={{info.topicProgressExpanded}} @enteredIndex={{this.enteredIndex}} @loading={{this.model.postStream.loading}} @jumpToPost={{action "jumpToPost"}} @jumpTop={{action "jumpTop"}} @jumpBottom={{action "jumpBottom"}} @jumpEnd={{action "jumpEnd"}} @jumpToPostPrompt={{action "jumpToPostPrompt"}} @jumpToIndex={{action "jumpToIndex"}} @replyToPost={{action "replyToPost"}} @showSummary={{action "showSummary"}} @toggleMultiSelect={{action "toggleMultiSelect"}} @showTopicSlowModeUpdate={{route-action "showTopicSlowModeUpdate"}} @deleteTopic={{action "deleteTopic"}} @recoverTopic={{action "recoverTopic"}} @toggleClosed={{action "toggleClosed"}} @toggleArchived={{action "toggleArchived"}} @toggleVisibility={{action "toggleVisibility"}} @showTopicTimerModal={{route-action "showTopicTimerModal"}} @showFeatureTopic={{route-action "showFeatureTopic"}} @showChangeTimestamp={{route-action "showChangeTimestamp"}} @resetBumpDate={{action "resetBumpDate"}} @convertToPublicTopic={{action "convertToPublicTopic"}} @convertToPrivateMessage={{action "convertToPrivateMessage"}} />
{{/if}}
{{else}}
<TopicProgress @prevEvent={{info.prevEvent}} @topic={{this.model}} @expanded={{info.topicProgressExpanded}} @jumpToPost={{action "jumpToPost"}}>
<PluginOutlet @name="before-topic-progress" @tagName="span" @connectorTagName="div" @args={{hash model=this.model jumpToPost=(action "jumpToPost")}} />

View File

@ -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: "<p>Deleted post</p>",
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: "<p>Whisper post</p>",
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. Its important! \nEdit this into a brief description of your community: \n\nWho is it for?\nWhat can they fi&hellip;",
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"
);
});
});

View File

@ -234,6 +234,9 @@
.widget-dragging & {
transition: none;
}
.dragging & {
transition: none;
}
}
.timeline-handle {

View File

@ -12,6 +12,10 @@ body.widget-dragging {
cursor: ns-resize;
}
body.dragging {
cursor: ns-resize;
}
// Common classes
.boxed {
height: 100%;

View File

@ -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

View File

@ -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."

View File

@ -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: