mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
UX: Merge the simplified topic map (#27964)
Replaces the existing topic map with the experimental-topic-map made by @awesomerobot. --------- Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
This commit is contained in:
@@ -1,131 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||
import PrivateMessageMap from "discourse/components/topic-map/private-message-map";
|
||||
import TopicMapExpanded from "discourse/components/topic-map/topic-map-expanded";
|
||||
import TopicMapSummary from "discourse/components/topic-map/topic-map-summary";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import I18n from "discourse-i18n";
|
||||
|
||||
const MIN_POST_READ_TIME = 4;
|
||||
|
||||
export default class TopicMap extends Component {
|
||||
@service siteSettings;
|
||||
@tracked collapsed = !this.args.model.has_summary;
|
||||
|
||||
get userFilters() {
|
||||
return this.args.postStream.userFilters || [];
|
||||
}
|
||||
|
||||
@action
|
||||
toggleMap() {
|
||||
this.collapsed = !this.collapsed;
|
||||
}
|
||||
|
||||
get topRepliesSummaryInfo() {
|
||||
if (this.topRepliesSummaryEnabled) {
|
||||
return I18n.t("summary.enabled_description");
|
||||
}
|
||||
|
||||
const wordCount = this.args.model.word_count;
|
||||
if (wordCount && this.siteSettings.read_time_word_count > 0) {
|
||||
const readingTime = Math.ceil(
|
||||
Math.max(
|
||||
wordCount / this.siteSettings.read_time_word_count,
|
||||
(this.args.model.posts_count * MIN_POST_READ_TIME) / 60
|
||||
)
|
||||
);
|
||||
return I18n.messageFormat("summary.description_time_MF", {
|
||||
replyCount: this.args.model.replyCount,
|
||||
readingTime,
|
||||
});
|
||||
}
|
||||
return I18n.t("summary.description", {
|
||||
count: this.args.model.replyCount,
|
||||
});
|
||||
}
|
||||
|
||||
get topRepliesTitle() {
|
||||
if (this.topRepliesSummaryEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return I18n.t("summary.short_title");
|
||||
}
|
||||
|
||||
get topRepliesLabel() {
|
||||
const label = this.topRepliesSummaryEnabled
|
||||
? "summary.disable"
|
||||
: "summary.enable";
|
||||
|
||||
return I18n.t(label);
|
||||
}
|
||||
|
||||
get topRepliesIcon() {
|
||||
if (this.topRepliesSummaryEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return "layer-group";
|
||||
}
|
||||
|
||||
<template>
|
||||
<section class={{concatClass "map" (if this.collapsed "map-collapsed")}}>
|
||||
<TopicMapSummary
|
||||
@topic={{@model}}
|
||||
@topicDetails={{@topicDetails}}
|
||||
@toggleMap={{this.toggleMap}}
|
||||
@collapsed={{this.collapsed}}
|
||||
@userFilters={{this.userFilters}}
|
||||
/>
|
||||
</section>
|
||||
{{#unless this.collapsed}}
|
||||
<section
|
||||
class="topic-map-expanded"
|
||||
id="topic-map-expanded__aria-controls"
|
||||
>
|
||||
<TopicMapExpanded
|
||||
@topicDetails={{@topicDetails}}
|
||||
@userFilters={{this.userFilters}}
|
||||
/>
|
||||
</section>
|
||||
{{/unless}}
|
||||
|
||||
<section class="information toggle-summary">
|
||||
{{#if @model.has_summary}}
|
||||
<p>{{htmlSafe this.topRepliesSummaryInfo}}</p>
|
||||
{{/if}}
|
||||
<PluginOutlet
|
||||
@name="topic-map-expanded-after"
|
||||
@defaultGlimmer={{true}}
|
||||
@outletArgs={{hash topic=@model postStream=@postStream}}
|
||||
>
|
||||
{{#if @model.has_summary}}
|
||||
<DButton
|
||||
@action={{if @postStream.summary @cancelFilter @showTopReplies}}
|
||||
@translatedTitle={{this.topRepliesTitle}}
|
||||
@translatedLabel={{this.topRepliesLabel}}
|
||||
@icon={{this.topRepliesIcon}}
|
||||
class="top-replies"
|
||||
/>
|
||||
{{/if}}
|
||||
</PluginOutlet>
|
||||
</section>
|
||||
|
||||
{{#if @showPMMap}}
|
||||
<section class="information private-message-map">
|
||||
<PrivateMessageMap
|
||||
@topicDetails={{@topicDetails}}
|
||||
@showInvite={{@showInvite}}
|
||||
@removeAllowedGroup={{@removeAllowedGroup}}
|
||||
@removeAllowedUser={{@removeAllowedUser}}
|
||||
/>
|
||||
</section>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { hash } from "@ember/helper";
|
||||
import PluginOutlet from "discourse/components/plugin-outlet";
|
||||
import PrivateMessageMap from "discourse/components/topic-map/private-message-map";
|
||||
import TopicMapSummary from "discourse/components/topic-map/topic-map-summary";
|
||||
|
||||
const TopicMap = <template>
|
||||
{{#unless @model.postStream.loadingFilter}}
|
||||
<section class="topic-map__contents">
|
||||
<TopicMapSummary
|
||||
@topic={{@model}}
|
||||
@topicDetails={{@topicDetails}}
|
||||
@postStream={{@postStream}}
|
||||
/>
|
||||
</section>
|
||||
{{/unless}}
|
||||
|
||||
<section class="topic-map__additional-contents toggle-summary">
|
||||
<PluginOutlet
|
||||
@name="topic-map-expanded-after"
|
||||
@defaultGlimmer={{true}}
|
||||
@outletArgs={{hash topic=@model postStream=@postStream}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{{#if @showPMMap}}
|
||||
<section class="topic-map__private-message-map">
|
||||
<PrivateMessageMap
|
||||
@topicDetails={{@topicDetails}}
|
||||
@showInvite={{@showInvite}}
|
||||
@removeAllowedGroup={{@removeAllowedGroup}}
|
||||
@removeAllowedUser={{@removeAllowedUser}}
|
||||
/>
|
||||
</section>
|
||||
{{/if}}
|
||||
</template>;
|
||||
|
||||
export default TopicMap;
|
||||
@@ -1,133 +0,0 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import TopicParticipants from "discourse/components/topic-map/topic-participants";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
import lt from "truth-helpers/helpers/lt";
|
||||
import not from "truth-helpers/helpers/not";
|
||||
|
||||
const TRUNCATED_LINKS_LIMIT = 5;
|
||||
|
||||
export default class TopicMapExpanded extends Component {
|
||||
@tracked allLinksShown = false;
|
||||
|
||||
get topicLinks() {
|
||||
return this.args.topicDetails.links;
|
||||
}
|
||||
|
||||
get participants() {
|
||||
return this.args.topicDetails.participants;
|
||||
}
|
||||
|
||||
@action
|
||||
showAllLinks() {
|
||||
this.allLinksShown = true;
|
||||
}
|
||||
|
||||
get linksToShow() {
|
||||
return this.allLinksShown
|
||||
? this.topicLinks
|
||||
: this.topicLinks.slice(0, TRUNCATED_LINKS_LIMIT);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.participants}}
|
||||
<section class="avatars">
|
||||
<TopicParticipants
|
||||
@title={{i18n "topic_map.participants_title"}}
|
||||
@userFilters={{@userFilters}}
|
||||
@participants={{this.participants}}
|
||||
/>
|
||||
</section>
|
||||
{{/if}}
|
||||
{{#if this.topicLinks}}
|
||||
<section class="links">
|
||||
<h3>{{i18n "topic_map.links_title"}}</h3>
|
||||
<table class="topic-links">
|
||||
<tbody>
|
||||
{{#each this.linksToShow as |link|}}
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-notification clicks"
|
||||
title={{i18n "topic_map.clicks" count=link.clicks}}
|
||||
>
|
||||
{{link.clicks}}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<TopicMapLink
|
||||
@attachment={{link.attachment}}
|
||||
@title={{link.title}}
|
||||
@rootDomain={{link.root_domain}}
|
||||
@url={{link.url}}
|
||||
@userId={{link.user_id}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{#if
|
||||
(and
|
||||
(not this.allLinksShown)
|
||||
(lt TRUNCATED_LINKS_LIMIT this.topicLinks.length)
|
||||
)
|
||||
}}
|
||||
<div class="link-summary">
|
||||
<span>
|
||||
<DButton
|
||||
@action={{this.showAllLinks}}
|
||||
@title="topic_map.links_shown"
|
||||
@icon="chevron-down"
|
||||
class="btn-flat"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</section>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
class TopicMapLink extends Component {
|
||||
get linkClasses() {
|
||||
return this.args.attachment
|
||||
? "topic-link track-link attachment"
|
||||
: "topic-link track-link";
|
||||
}
|
||||
|
||||
get truncatedContent() {
|
||||
const truncateLength = 85;
|
||||
const content = this.args.title || this.args.url;
|
||||
return content.length > truncateLength
|
||||
? `${content.slice(0, truncateLength).trim()}...`
|
||||
: content;
|
||||
}
|
||||
|
||||
<template>
|
||||
<a
|
||||
class={{this.linkClasses}}
|
||||
href={{@url}}
|
||||
title={{@url}}
|
||||
data-user-id={{@userId}}
|
||||
data-ignore-post-id="true"
|
||||
target="_blank"
|
||||
rel="nofollow ugc noopener noreferrer"
|
||||
>
|
||||
{{#if @title}}
|
||||
{{replaceEmoji this.truncatedContent}}
|
||||
{{else}}
|
||||
{{this.truncatedContent}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{#if (and @title @rootDomain)}}
|
||||
<span class="domain">
|
||||
{{@rootDomain}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Component from "@glimmer/component";
|
||||
import replaceEmoji from "discourse/helpers/replace-emoji";
|
||||
import and from "truth-helpers/helpers/and";
|
||||
|
||||
const TRUNCATE_LENGTH_LIMIT = 85;
|
||||
|
||||
export default class TopicMapLink extends Component {
|
||||
get linkClasses() {
|
||||
return this.args.attachment
|
||||
? "topic-link track-link attachment"
|
||||
: "topic-link track-link";
|
||||
}
|
||||
|
||||
get truncatedContent() {
|
||||
const content = this.args.title || this.args.url;
|
||||
return content.length > TRUNCATE_LENGTH_LIMIT
|
||||
? `${content.slice(0, TRUNCATE_LENGTH_LIMIT).trim()}...`
|
||||
: content;
|
||||
}
|
||||
|
||||
<template>
|
||||
<a
|
||||
class={{this.linkClasses}}
|
||||
href={{@url}}
|
||||
title={{@url}}
|
||||
data-user-id={{@userId}}
|
||||
data-ignore-post-id="true"
|
||||
target="_blank"
|
||||
rel="nofollow ugc noopener noreferrer"
|
||||
>
|
||||
{{#if @title}}
|
||||
{{replaceEmoji this.truncatedContent}}
|
||||
{{else}}
|
||||
{{this.truncatedContent}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{#if (and @title @rootDomain)}}
|
||||
<span class="domain">
|
||||
{{@rootDomain}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -1,162 +1,440 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { hash } from "@ember/helper";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import { gt } from "truth-helpers";
|
||||
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import RelativeDate from "discourse/components/relative-date";
|
||||
import TopicMapLink from "discourse/components/topic-map/topic-map-link";
|
||||
import TopicParticipants from "discourse/components/topic-map/topic-participants";
|
||||
import TopicViews from "discourse/components/topic-map/topic-views";
|
||||
import TopicViewsChart from "discourse/components/topic-map/topic-views-chart";
|
||||
import avatar from "discourse/helpers/bound-avatar-template";
|
||||
import number from "discourse/helpers/number";
|
||||
import slice from "discourse/helpers/slice";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { emojiUnescape } from "discourse/lib/text";
|
||||
import dIcon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import { avatarImg } from "discourse-common/lib/avatar-utils";
|
||||
import I18n from "discourse-i18n";
|
||||
import DMenu from "float-kit/components/d-menu";
|
||||
|
||||
const TRUNCATED_LINKS_LIMIT = 5;
|
||||
const MIN_POST_READ_TIME_MINUTES = 4;
|
||||
const MIN_READ_TIME_MINUTES = 3;
|
||||
const MIN_LIKES_COUNT = 5;
|
||||
const MIN_PARTICIPANTS_COUNT = 5;
|
||||
const MIN_USERS_COUNT_FOR_AVATARS = 2;
|
||||
|
||||
export const MIN_POSTS_COUNT = 10;
|
||||
|
||||
export default class TopicMapSummary extends Component {
|
||||
@service site;
|
||||
@service siteSettings;
|
||||
@service mapCache;
|
||||
@service dialog;
|
||||
|
||||
@tracked allLinksShown = false;
|
||||
@tracked top3LikedPosts = [];
|
||||
@tracked views = [];
|
||||
@tracked loading = true;
|
||||
|
||||
get shouldShowParticipants() {
|
||||
return (
|
||||
this.args.topic.posts_count >= MIN_POSTS_COUNT &&
|
||||
this.args.topicDetails.participants?.length >=
|
||||
MIN_USERS_COUNT_FOR_AVATARS &&
|
||||
!this.site.mobileView
|
||||
);
|
||||
}
|
||||
|
||||
get first5Participants() {
|
||||
return this.args.topicDetails.participants.slice(0, MIN_PARTICIPANTS_COUNT);
|
||||
}
|
||||
|
||||
get readTimeMinutes() {
|
||||
const calculatedTime = Math.ceil(
|
||||
Math.max(
|
||||
this.args.topic.word_count / this.siteSettings.read_time_word_count,
|
||||
(this.args.topic.posts_count * MIN_POST_READ_TIME_MINUTES) / 60
|
||||
)
|
||||
);
|
||||
|
||||
return calculatedTime > MIN_READ_TIME_MINUTES ? calculatedTime : null;
|
||||
}
|
||||
|
||||
get topRepliesSummaryEnabled() {
|
||||
return this.args.postStream.summary;
|
||||
}
|
||||
|
||||
get topRepliesTitle() {
|
||||
if (this.topRepliesSummaryEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return I18n.t("summary.short_title");
|
||||
}
|
||||
|
||||
get topRepliesIcon() {
|
||||
return this.topRepliesSummaryEnabled ? "arrows-alt-v" : "layer-group";
|
||||
}
|
||||
|
||||
get topRepliesLabel() {
|
||||
return this.topRepliesSummaryEnabled
|
||||
? I18n.t("summary.show_all_label")
|
||||
: I18n.t("summary.short_label");
|
||||
}
|
||||
|
||||
get loneStat() {
|
||||
if (this.args.topic.has_summary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
[this.hasViews, this.hasLikes, this.hasUsers, this.hasLinks].filter(
|
||||
Boolean
|
||||
).length === 1
|
||||
);
|
||||
}
|
||||
|
||||
get shouldShowViewsChart() {
|
||||
return this.views.stats.length > 2;
|
||||
}
|
||||
|
||||
get linksCount() {
|
||||
return this.args.topicDetails.links?.length ?? 0;
|
||||
}
|
||||
|
||||
get createdByUsername() {
|
||||
return this.args.topicDetails.created_by?.username;
|
||||
get topicLinks() {
|
||||
return this.args.topicDetails.links;
|
||||
}
|
||||
|
||||
get lastPosterUsername() {
|
||||
return this.args.topicDetails.last_poster?.username;
|
||||
get linksToShow() {
|
||||
return this.allLinksShown
|
||||
? this.topicLinks
|
||||
: this.topicLinks?.slice(0, TRUNCATED_LINKS_LIMIT);
|
||||
}
|
||||
|
||||
get toggleMapButton() {
|
||||
return {
|
||||
title: this.args.collapsed
|
||||
? "topic.expand_details"
|
||||
: "topic.collapse_details",
|
||||
icon: this.args.collapsed ? "chevron-down" : "chevron-up",
|
||||
ariaExpanded: this.args.collapsed ? "false" : "true",
|
||||
ariaControls: "topic-map-expanded__aria-controls",
|
||||
action: this.args.toggleMap,
|
||||
};
|
||||
get hasMoreLinks() {
|
||||
return !this.allLinksShown && this.linksCount > TRUNCATED_LINKS_LIMIT;
|
||||
}
|
||||
|
||||
get shouldShowParticipants() {
|
||||
get hasViews() {
|
||||
return this.args.topic.views > 1;
|
||||
}
|
||||
|
||||
get hasLikes() {
|
||||
return (
|
||||
this.args.collapsed &&
|
||||
this.args.topic.posts_count > 2 &&
|
||||
this.args.topicDetails.participants &&
|
||||
this.args.topicDetails.participants.length > 0
|
||||
this.args.topic.like_count > MIN_LIKES_COUNT &&
|
||||
this.args.topic.posts_count > MIN_POSTS_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
get createdByAvatar() {
|
||||
return htmlSafe(
|
||||
avatarImg({
|
||||
avatarTemplate: this.args.topicDetails.created_by?.avatar_template,
|
||||
size: "tiny",
|
||||
title:
|
||||
this.args.topicDetails.created_by?.name ||
|
||||
this.args.topicDetails.created_by?.username,
|
||||
})
|
||||
);
|
||||
get hasUsers() {
|
||||
return this.args.topic.participant_count > MIN_PARTICIPANTS_COUNT;
|
||||
}
|
||||
|
||||
get lastPostAvatar() {
|
||||
return htmlSafe(
|
||||
avatarImg({
|
||||
avatarTemplate: this.args.topicDetails.last_poster?.avatar_template,
|
||||
size: "tiny",
|
||||
title:
|
||||
this.args.topicDetails.last_poster?.name ||
|
||||
this.args.topicDetails.last_poster?.username,
|
||||
get hasLinks() {
|
||||
return this.linksCount > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
showAllLinks() {
|
||||
this.allLinksShown = true;
|
||||
}
|
||||
|
||||
@action
|
||||
showTopReplies() {
|
||||
this.args.postStream.showTopReplies();
|
||||
}
|
||||
|
||||
@action
|
||||
cancelFilter() {
|
||||
this.args.postStream.cancelFilter();
|
||||
this.args.postStream.refresh();
|
||||
}
|
||||
|
||||
@action
|
||||
postUrl(post) {
|
||||
return this.args.topic.urlForPostNumber(post.post_number);
|
||||
}
|
||||
|
||||
@action
|
||||
fetchMostLiked() {
|
||||
const cacheKey = `top3LikedPosts_${this.args.topic.id}`;
|
||||
const cachedData = this.mapCache.get(cacheKey);
|
||||
this.loading = true;
|
||||
|
||||
if (cachedData) {
|
||||
this.top3LikedPosts = cachedData;
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = `/search.json?q=" " topic%3A${this.args.topic.id} order%3Alikes`;
|
||||
|
||||
ajax(filter)
|
||||
.then((data) => {
|
||||
const top3LikedPosts = data.posts
|
||||
.filter((post) => post.post_number > 1 && post.like_count > 0)
|
||||
.sort((a, b) => b.like_count - a.like_count)
|
||||
.slice(0, 3);
|
||||
|
||||
this.mapCache.set(cacheKey, top3LikedPosts);
|
||||
this.top3LikedPosts = top3LikedPosts;
|
||||
})
|
||||
);
|
||||
.catch((error) => {
|
||||
this.dialog.alert(
|
||||
I18n.t("generic_error_with_reason", {
|
||||
error: `http: ${error.status} - ${error.body}`,
|
||||
})
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
fetchViews() {
|
||||
const cacheKey = `topicViews_${this.args.topic.id}`;
|
||||
const cachedData = this.mapCache.get(cacheKey);
|
||||
this.loading = true;
|
||||
|
||||
if (cachedData) {
|
||||
this.views = cachedData;
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ajax(`/t/${this.args.topic.id}/view-stats.json`)
|
||||
.then((data) => {
|
||||
if (data.stats.length) {
|
||||
this.views = data;
|
||||
} else {
|
||||
data.stats.push({
|
||||
viewed_at: new Date().toISOString().split("T")[0],
|
||||
views: this.args.topic.views,
|
||||
});
|
||||
this.views = data;
|
||||
}
|
||||
this.mapCache.set(cacheKey, data);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.dialog.alert(
|
||||
I18n.t("generic_error_with_reason", {
|
||||
error: `http: ${error.status} - ${error.body}`,
|
||||
})
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<nav class="buttons">
|
||||
<DButton
|
||||
@icon={{this.toggleMapButton.icon}}
|
||||
@title={{this.toggleMapButton.title}}
|
||||
@ariaExpanded={{this.toggleMapButton.ariaExpanded}}
|
||||
@ariaControls={{this.toggleMapButton.ariaControls}}
|
||||
@action={{this.toggleMapButton.action}}
|
||||
class="btn"
|
||||
/>
|
||||
</nav>
|
||||
<ul>
|
||||
<li class="created-at">
|
||||
<h4 role="presentation">{{i18n "created_lowercase"}}</h4>
|
||||
<div class="topic-map-post created-at">
|
||||
<a
|
||||
class="trigger-user-card"
|
||||
data-user-card={{this.createdByUsername}}
|
||||
title={{this.createdByUsername}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{this.createdByAvatar}}
|
||||
<RelativeDate @date={{@topic.created_at}} />
|
||||
</div>
|
||||
</li>
|
||||
<li class="last-reply">
|
||||
<a href={{@topic.lastPostUrl}}>
|
||||
<h4 role="presentation">{{i18n "last_reply_lowercase"}}</h4>
|
||||
<div class="topic-map-post last-reply">
|
||||
<a
|
||||
class="trigger-user-card"
|
||||
data-user-card={{this.lastPosterUsername}}
|
||||
title={{this.lastPosterUsername}}
|
||||
aria-hidden="true"
|
||||
<div class="topic-map__stats {{if this.loneStat '--single-stat'}}">
|
||||
<DMenu
|
||||
@arrow={{true}}
|
||||
@identifier="topic-map__views"
|
||||
@interactive={{true}}
|
||||
@triggers="click"
|
||||
@modalForMobile={{true}}
|
||||
@placement="right"
|
||||
@groupIdentifier="topic-map"
|
||||
@inline={{true}}
|
||||
@onShow={{this.fetchViews}}
|
||||
>
|
||||
<:trigger>
|
||||
{{number @topic.views noTitle="true"}}
|
||||
<span class="topic-map__stat-label">
|
||||
{{i18n "views_lowercase" count=@topic.views}}
|
||||
</span>
|
||||
</:trigger>
|
||||
<:content>
|
||||
<h3>{{i18n "topic_map.menu_titles.views"}}</h3>
|
||||
<ConditionalLoadingSpinner @condition={{this.loading}}>
|
||||
{{#if this.shouldShowViewsChart}}
|
||||
<TopicViewsChart
|
||||
@views={{this.views}}
|
||||
@created={{@topic.created_at}}
|
||||
/>
|
||||
{{else}}
|
||||
<TopicViews @views={{this.views}} />
|
||||
{{/if}}
|
||||
</ConditionalLoadingSpinner>
|
||||
</:content>
|
||||
</DMenu>
|
||||
|
||||
{{#if this.hasLikes}}
|
||||
<DMenu
|
||||
@arrow={{true}}
|
||||
@identifier="topic-map__likes"
|
||||
@interactive={{true}}
|
||||
@triggers="click"
|
||||
@modalForMobile={{true}}
|
||||
@placement="right"
|
||||
@groupIdentifier="topic-map"
|
||||
@inline={{true}}
|
||||
>
|
||||
<:trigger>
|
||||
{{number @topic.like_count noTitle="true"}}
|
||||
<span class="topic-map__stat-label">
|
||||
{{i18n "likes_lowercase" count=@topic.like_count}}
|
||||
</span>
|
||||
</:trigger>
|
||||
<:content>
|
||||
<h3 {{didInsert this.fetchMostLiked}}>{{i18n
|
||||
"topic_map.menu_titles.replies"
|
||||
}}</h3>
|
||||
<ConditionalLoadingSpinner @condition={{this.loading}}>
|
||||
<ul>
|
||||
{{#each this.top3LikedPosts as |post|}}
|
||||
<li>
|
||||
<a href={{this.postUrl post}}>
|
||||
<span class="like-section__user">
|
||||
{{avatar
|
||||
post.avatar_template
|
||||
"tiny"
|
||||
(hash title=post.username)
|
||||
}}
|
||||
{{post.username}}
|
||||
</span>
|
||||
<span class="like-section__likes">
|
||||
{{post.like_count}}
|
||||
{{dIcon "heart"}}</span>
|
||||
<p>
|
||||
{{htmlSafe (emojiUnescape post.blurb)}}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</ConditionalLoadingSpinner>
|
||||
</:content>
|
||||
</DMenu>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.linksCount}}
|
||||
<DMenu
|
||||
@arrow={{true}}
|
||||
@identifier="topic-map__links"
|
||||
@interactive={{true}}
|
||||
@triggers="click"
|
||||
@modalForMobile={{true}}
|
||||
@groupIdentifier="topic-map"
|
||||
@placement="right"
|
||||
@inline={{true}}
|
||||
>
|
||||
<:trigger>
|
||||
{{number this.linksCount noTitle="true"}}
|
||||
<span class="topic-map__stat-label">
|
||||
{{i18n "links_lowercase" count=this.linksCount}}
|
||||
</span>
|
||||
</:trigger>
|
||||
<:content>
|
||||
<h3>{{i18n "topic_map.links_title"}}</h3>
|
||||
<table class="topic-links">
|
||||
<tbody>
|
||||
{{#each this.linksToShow as |link|}}
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-notification clicks"
|
||||
title={{i18n "topic_map.clicks" count=link.clicks}}
|
||||
>
|
||||
{{link.clicks}}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<TopicMapLink
|
||||
@attachment={{link.attachment}}
|
||||
@title={{link.title}}
|
||||
@rootDomain={{link.root_domain}}
|
||||
@url={{link.url}}
|
||||
@userId={{link.user_id}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{#if this.hasMoreLinks}}
|
||||
<div class="link-summary">
|
||||
<span>
|
||||
<DButton
|
||||
@action={{this.showAllLinks}}
|
||||
@title="topic_map.links_shown"
|
||||
@icon="chevron-down"
|
||||
class="btn-flat"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</:content>
|
||||
</DMenu>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.hasUsers}}
|
||||
<DMenu
|
||||
@arrow={{true}}
|
||||
@identifier="topic-map__users"
|
||||
@interactive={{true}}
|
||||
@triggers="click"
|
||||
@placement="right"
|
||||
@modalForMobile={{true}}
|
||||
@groupIdentifier="topic-map"
|
||||
@inline={{true}}
|
||||
>
|
||||
<:trigger>
|
||||
{{number @topic.participant_count noTitle="true"}}
|
||||
<span class="topic-map__stat-label">
|
||||
{{i18n "users_lowercase" count=@topic.participant_count}}
|
||||
</span>
|
||||
</:trigger>
|
||||
<:content>
|
||||
<TopicParticipants
|
||||
@title={{i18n "topic_map.participants_title"}}
|
||||
@userFilters={{@userFilters}}
|
||||
@participants={{@topicDetails.participants}}
|
||||
/>
|
||||
{{this.lastPostAvatar}}
|
||||
<RelativeDate @date={{@topic.last_posted_at}} />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="replies">
|
||||
{{number @topic.replyCount noTitle="true"}}
|
||||
<h4 role="presentation">{{i18n
|
||||
"replies_lowercase"
|
||||
count=@topic.replyCount
|
||||
}}</h4>
|
||||
</li>
|
||||
<li class="secondary views">
|
||||
{{number @topic.views noTitle="true" class=@topic.viewsHeat}}
|
||||
<h4 role="presentation">{{i18n
|
||||
"views_lowercase"
|
||||
count=@topic.views
|
||||
}}</h4>
|
||||
</li>
|
||||
{{#if (gt @topic.participant_count 0)}}
|
||||
<li class="secondary users">
|
||||
{{number @topic.participant_count noTitle="true"}}
|
||||
<h4 role="presentation">{{i18n
|
||||
"users_lowercase"
|
||||
count=@topic.participant_count
|
||||
}}</h4>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (gt @topic.like_count 0)}}
|
||||
<li class="secondary likes">
|
||||
{{number @topic.like_count noTitle="true"}}
|
||||
<h4 role="presentation">{{i18n
|
||||
"likes_lowercase"
|
||||
count=@topic.like_count
|
||||
}}</h4>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (gt this.linksCount 0)}}
|
||||
<li class="secondary links">
|
||||
{{number this.linksCount noTitle="true"}}
|
||||
<h4 role="presentation">{{i18n
|
||||
"links_lowercase"
|
||||
count=this.linksCount
|
||||
}}</h4>
|
||||
</li>
|
||||
</:content>
|
||||
</DMenu>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.shouldShowParticipants}}
|
||||
<li class="avatars">
|
||||
<TopicParticipants
|
||||
@participants={{slice 0 3 @topicDetails.participants}}
|
||||
@userFilters={{@userFilters}}
|
||||
/>
|
||||
</li>
|
||||
<TopicParticipants
|
||||
@participants={{this.first5Participants}}
|
||||
@userFilters={{@userFilters}}
|
||||
/>
|
||||
{{/if}}
|
||||
</ul>
|
||||
<div class="topic-map__buttons">
|
||||
{{#if this.readTimeMinutes}}
|
||||
<div class="estimated-read-time">
|
||||
<span> {{i18n "topic_map.read"}} </span>
|
||||
<span>
|
||||
{{this.readTimeMinutes}}
|
||||
{{i18n "topic_map.minutes"}}
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="summarization-buttons">
|
||||
{{#if @topic.has_summary}}
|
||||
<DButton
|
||||
@action={{if
|
||||
@postStream.summary
|
||||
this.cancelFilter
|
||||
this.showTopReplies
|
||||
}}
|
||||
@translatedTitle={{this.topRepliesTitle}}
|
||||
@translatedLabel={{this.topRepliesLabel}}
|
||||
@icon={{this.topRepliesIcon}}
|
||||
class="top-replies"
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ export default class TopicParticipants extends Component {
|
||||
{{#if @title}}
|
||||
<h3>{{@title}}</h3>
|
||||
{{/if}}
|
||||
{{#each @participants as |participant|}}
|
||||
<TopicParticipant
|
||||
@participant={{participant}}
|
||||
@toggledUsers={{this.toggledUsers}}
|
||||
/>
|
||||
{{/each}}
|
||||
<div class="topic-map__users-list {{unless @title '--users-summary'}}">
|
||||
{{#each @participants as |participant|}}
|
||||
<TopicParticipant
|
||||
@participant={{participant}}
|
||||
@toggledUsers={{this.toggledUsers}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import loadScript from "discourse/lib/load-script";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import I18n from "discourse-i18n";
|
||||
|
||||
const oneDay = 86400000; // day in milliseconds
|
||||
|
||||
const now = new Date();
|
||||
const startOfDay = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate()
|
||||
);
|
||||
|
||||
function fillMissingDates(data) {
|
||||
const filledData = [];
|
||||
let currentDate = data[0].x;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
while (currentDate < data[i].x) {
|
||||
filledData.push({ x: currentDate, y: 0 });
|
||||
currentDate += oneDay;
|
||||
}
|
||||
filledData.push(data[i]);
|
||||
currentDate = data[i].x + oneDay;
|
||||
}
|
||||
|
||||
return filledData;
|
||||
}
|
||||
|
||||
function weightedMovingAverage(data, period = 3) {
|
||||
const weights = Array.from({ length: period }, (_, i) => i + 1);
|
||||
const weightSum = weights.reduce((a, b) => a + b, 0);
|
||||
let result = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i < period - 1) {
|
||||
result.push(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
let weightedSum = 0;
|
||||
for (let j = 0; j < period; j++) {
|
||||
weightedSum += data[i - j].y * weights[j];
|
||||
}
|
||||
|
||||
result.push(Math.round(weightedSum / weightSum));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function predictTodaysViews(data) {
|
||||
const movingAvg = weightedMovingAverage(data);
|
||||
const lastMovingAvg = movingAvg[movingAvg.length - 1];
|
||||
const currentViews = data[data.length - 1].y;
|
||||
const currentTimeUTC = Date.now() + now.getTimezoneOffset() * 60 * 1000;
|
||||
const elapsedTime = (currentTimeUTC - startOfDay) / oneDay; // amount of day passed
|
||||
let adjustedPrediction = lastMovingAvg;
|
||||
|
||||
if (currentViews >= lastMovingAvg) {
|
||||
// If higher than the average prediction, extrapolate
|
||||
adjustedPrediction =
|
||||
currentViews + (currentViews - lastMovingAvg) * (1 - elapsedTime);
|
||||
} else {
|
||||
// If views are lower than the average, adjust towards average
|
||||
adjustedPrediction = currentViews + lastMovingAvg * (1 - elapsedTime);
|
||||
}
|
||||
return Math.round(Math.max(adjustedPrediction, currentViews)); // never lower than actual data
|
||||
}
|
||||
|
||||
export default class TopicViewsChart extends Component {
|
||||
chart = null;
|
||||
noData = false;
|
||||
|
||||
@action
|
||||
async renderChart(element) {
|
||||
await loadScript("/javascripts/Chart.min.js");
|
||||
|
||||
if (!this.args.views?.stats || this.args.views?.stats?.length === 0) {
|
||||
this.noData = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let data = this.args.views.stats.map((item) => ({
|
||||
x: new Date(`${item.viewed_at}T00:00:00Z`).getTime(), // Use UTC time
|
||||
y: item.views,
|
||||
}));
|
||||
|
||||
data = fillMissingDates(data);
|
||||
|
||||
const lastDay = data[data.length - 1];
|
||||
|
||||
const predictedViews = predictTodaysViews(data);
|
||||
const predictedDataPoint = {
|
||||
x: lastDay.x,
|
||||
y: predictedViews,
|
||||
};
|
||||
|
||||
// remove current day's actual point, we'll replace with prediction
|
||||
data = data.slice(0, data.length - 1);
|
||||
// Add predicted data point
|
||||
data.push(predictedDataPoint);
|
||||
|
||||
const context = element.getContext("2d");
|
||||
|
||||
const xMin = data[0].x;
|
||||
const xMax = lastDay.x;
|
||||
|
||||
const topicMapElement = document.querySelector(".topic-map");
|
||||
|
||||
// grab colors from CSS
|
||||
const lineColor =
|
||||
getComputedStyle(topicMapElement).getPropertyValue("--chart-line-color");
|
||||
const pointColor = getComputedStyle(topicMapElement).getPropertyValue(
|
||||
"--chart-point-color"
|
||||
);
|
||||
const predictionColor = getComputedStyle(topicMapElement).getPropertyValue(
|
||||
"--chart-prediction-color"
|
||||
);
|
||||
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
|
||||
this.chart = new window.Chart(context, {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: "Views",
|
||||
data: data.slice(0, -1),
|
||||
showLine: true,
|
||||
borderColor: pointColor,
|
||||
backgroundColor: lineColor,
|
||||
pointBackgroundColor: pointColor,
|
||||
},
|
||||
{
|
||||
label: "Predicted Views",
|
||||
data: [data[data.length - 2], data[data.length - 1]],
|
||||
showLine: true,
|
||||
borderDash: [5, 5],
|
||||
borderColor: predictionColor,
|
||||
backgroundColor: predictionColor,
|
||||
pointBackgroundColor: predictionColor,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
x: {
|
||||
type: "linear",
|
||||
position: "bottom",
|
||||
min: xMin,
|
||||
max: xMax,
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
stepSize: oneDay,
|
||||
maxTicksLimit: 15,
|
||||
callback: function (value) {
|
||||
const date = new Date(value + oneDay);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function (value) {
|
||||
return value;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function (tooltipItem) {
|
||||
let date = new Date(tooltipItem[0]?.parsed?.x + oneDay);
|
||||
if (tooltipItem.length === 0) {
|
||||
const today = new Date();
|
||||
date = today.getUTCDate();
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
},
|
||||
label: function (tooltipItem) {
|
||||
const label =
|
||||
tooltipItem?.parsed?.x === startOfDay
|
||||
? I18n.t("topic_map.predicted_views")
|
||||
: I18n.t("topic_map.views");
|
||||
|
||||
return `${label}: ${tooltipItem?.parsed?.y}`;
|
||||
},
|
||||
},
|
||||
filter: function (tooltipItem) {
|
||||
return !(
|
||||
tooltipItem?.parsed?.x === startOfDay - oneDay &&
|
||||
tooltipItem?.datasetIndex === 1
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.noData}}
|
||||
{{i18n "topic_map.chart_error"}}
|
||||
{{else}}
|
||||
<canvas {{didInsert this.renderChart}}></canvas>
|
||||
<div class="view-explainer">{{i18n "topic_map.view_explainer"}}</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Component from "@glimmer/component";
|
||||
|
||||
export default class TopicViews extends Component {
|
||||
adjustAggregatedData(stats) {
|
||||
const adjustedStats = [];
|
||||
|
||||
stats.forEach((stat) => {
|
||||
const localDate = new Date(`${stat.viewed_at}T00:00:00Z`);
|
||||
const localDateStr = localDate.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
const existingStat = adjustedStats.find(
|
||||
(s) => s.dateStr === localDateStr
|
||||
);
|
||||
|
||||
if (existingStat) {
|
||||
existingStat.views += stat.views;
|
||||
} else {
|
||||
adjustedStats.push({
|
||||
dateStr: localDateStr,
|
||||
views: stat.views,
|
||||
localDate,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return adjustedStats.map((stat) => ({
|
||||
viewed_at: stat.localDate.toISOString().split("T")[0],
|
||||
views: stat.views,
|
||||
}));
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
get updatedStats() {
|
||||
const adjustedStats = this.adjustAggregatedData(this.args.views.stats);
|
||||
|
||||
let stats = adjustedStats.map((stat) => {
|
||||
const statDate = new Date(`${stat.viewed_at}T00:00:00`).getTime();
|
||||
const localStatDate = new Date(statDate);
|
||||
|
||||
return {
|
||||
...stat,
|
||||
statDate: localStatDate,
|
||||
label: this.formatDate(localStatDate),
|
||||
};
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="topic-views__wrapper">
|
||||
{{#each this.updatedStats as |stat|}}
|
||||
<div class="topic-views">
|
||||
<div class="topic-views__count">
|
||||
{{stat.views}}
|
||||
</div>
|
||||
<div class="topic-views__date">
|
||||
{{stat.label}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import ChangePostNoticeModal from "discourse/components/modal/change-post-notice
|
||||
import ConvertToPublicTopicModal from "discourse/components/modal/convert-to-public-topic";
|
||||
import DeleteTopicConfirmModal from "discourse/components/modal/delete-topic-confirm";
|
||||
import JumpToPost from "discourse/components/modal/jump-to-post";
|
||||
import { MIN_POSTS_COUNT } from "discourse/components/topic-map/topic-map-summary";
|
||||
import { spinnerHTML } from "discourse/helpers/loading-spinner";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
@@ -239,6 +240,11 @@ export default Controller.extend(bufferedProperty("model"), {
|
||||
return Category.findById(categoryId)?.minimumRequiredTags || 0;
|
||||
},
|
||||
|
||||
@discourseComputed("model.posts_count")
|
||||
showBottomTopicMap(postsCount) {
|
||||
return postsCount > MIN_POSTS_COUNT;
|
||||
},
|
||||
|
||||
_removeDeleteOnOwnerReplyBookmarks() {
|
||||
// the user has already navigated away from the topic. the PostCreator
|
||||
// in rails already handles deleting the bookmarks that need to be
|
||||
|
||||
35
app/assets/javascripts/discourse/app/services/map-cache.js
Normal file
35
app/assets/javascripts/discourse/app/services/map-cache.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import Service from "@ember/service";
|
||||
|
||||
export default class MapCache extends Service {
|
||||
cache = {};
|
||||
|
||||
get(key) {
|
||||
const cachedItem = this.cache[key];
|
||||
if (!cachedItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { value, timestamp, ttl } = cachedItem;
|
||||
const now = Date.now();
|
||||
|
||||
if (now - timestamp > ttl) {
|
||||
this.clear(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key, value, ttl = 120000) {
|
||||
// expires after 2 min
|
||||
this.cache[key] = {
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
ttl,
|
||||
};
|
||||
}
|
||||
|
||||
clear(key) {
|
||||
delete this.cache[key];
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,20 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.showBottomTopicMap}}
|
||||
<div class="topic-map --bottom">
|
||||
<TopicMap
|
||||
@model={{this.model}}
|
||||
@topicDetails={{this.model.details}}
|
||||
@postStream={{this.model.postStream}}
|
||||
@showPMMap={{eq this.model.archetype "private_message"}}
|
||||
@showInvite={{route-action "showInvite"}}
|
||||
@removeAllowedGroup={{action "removeAllowedGroup"}}
|
||||
@removeAllowedUser={{action "removeAllowedUser"}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<PluginOutlet @name="above-timeline" @connectorTagName="div" />
|
||||
|
||||
<TopicNavigation
|
||||
|
||||
@@ -743,43 +743,9 @@ createWidget("post-body", {
|
||||
result.push(postContents);
|
||||
result.push(this.attach("actions-summary", attrs));
|
||||
result.push(this.attach("post-links", attrs));
|
||||
if (attrs.showTopicMap) {
|
||||
result.push(this.buildTopicMap(attrs));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
buildTopicMap(attrs) {
|
||||
return new RenderGlimmer(
|
||||
this,
|
||||
"div.topic-map",
|
||||
hbs`<TopicMap
|
||||
@model={{@data.model}}
|
||||
@topicDetails={{@data.topicDetails}}
|
||||
@postStream={{@data.postStream}}
|
||||
@showPMMap={{@data.showPMMap}}
|
||||
@cancelFilter={{@data.cancelFilter}}
|
||||
@showTopReplies={{@data.showTopReplies}}
|
||||
@showInvite={{@data.showInvite}}
|
||||
@removeAllowedGroup={{@data.removeAllowedGroup}}
|
||||
@removeAllowedUser={{@data.removeAllowedUser}}
|
||||
/>`,
|
||||
{
|
||||
model: attrs.topic,
|
||||
topicDetails: attrs.topic.get("details"),
|
||||
postStream: attrs.topic.postStream,
|
||||
showPMMap: attrs.showPMMap,
|
||||
cancelFilter: () => this.sendWidgetAction("cancelFilter"),
|
||||
showTopReplies: () => this.sendWidgetAction("showTopReplies"),
|
||||
showInvite: () => this.sendWidgetAction("showInvite"),
|
||||
removeAllowedGroup: (group) =>
|
||||
this.sendWidgetAction("removeAllowedGroup", group),
|
||||
removeAllowedUser: (user) =>
|
||||
this.sendWidgetAction("removeAllowedUser", user),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
createWidget("post-article", {
|
||||
@@ -864,6 +830,11 @@ createWidget("post-article", {
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
if (attrs.showTopicMap) {
|
||||
rows.push(this.buildTopicMap(attrs));
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
|
||||
@@ -928,6 +899,33 @@ createWidget("post-article", {
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
buildTopicMap(attrs) {
|
||||
return new RenderGlimmer(
|
||||
this,
|
||||
"div.topic-map.--op",
|
||||
hbs`<TopicMap
|
||||
@model={{@data.model}}
|
||||
@topicDetails={{@data.topicDetails}}
|
||||
@postStream={{@data.postStream}}
|
||||
@showPMMap={{@data.showPMMap}}
|
||||
@showInvite={{@data.showInvite}}
|
||||
@removeAllowedGroup={{@data.removeAllowedGroup}}
|
||||
@removeAllowedUser={{@data.removeAllowedUser}}
|
||||
/>`,
|
||||
{
|
||||
model: attrs.topic,
|
||||
topicDetails: attrs.topic.get("details"),
|
||||
postStream: attrs.topic.postStream,
|
||||
showPMMap: attrs.showPMMap,
|
||||
showInvite: () => this.sendWidgetAction("showInvite"),
|
||||
removeAllowedGroup: (group) =>
|
||||
this.sendWidgetAction("removeAllowedGroup", group),
|
||||
removeAllowedUser: (user) =>
|
||||
this.sendWidgetAction("removeAllowedUser", user),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
let addPostClassesCallbacks = null;
|
||||
|
||||
@@ -91,7 +91,9 @@ acceptance("Personal Message - invite", function (needs) {
|
||||
test("can open invite modal", async function (assert) {
|
||||
await visit("/t/pm-for-testing/12");
|
||||
await click(".add-remove-participant-btn");
|
||||
await click(".private-message-map .controls .add-participant-btn");
|
||||
await click(
|
||||
".topic-map__private-message-map .controls .add-participant-btn"
|
||||
);
|
||||
|
||||
assert
|
||||
.dom(".d-modal.add-pm-participants .invite-user-control")
|
||||
@@ -101,7 +103,9 @@ acceptance("Personal Message - invite", function (needs) {
|
||||
test("shows errors correctly", async function (assert) {
|
||||
await visit("/t/pm-for-testing/12");
|
||||
await click(".add-remove-participant-btn");
|
||||
await click(".private-message-map .controls .add-participant-btn");
|
||||
await click(
|
||||
".topic-map__private-message-map .controls .add-participant-btn"
|
||||
);
|
||||
|
||||
assert
|
||||
.dom(".d-modal.add-pm-participants .invite-user-control")
|
||||
|
||||
@@ -661,7 +661,8 @@ acceptance("Topic stats update automatically", function () {
|
||||
test("Likes count updates automatically", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
|
||||
const likesCountSelectors = "#post_1 .topic-map .likes .number";
|
||||
const likesCountSelectors =
|
||||
"#post_1 .topic-map .topic-map__likes-trigger .number";
|
||||
const oldLikesCount = query(likesCountSelectors).textContent;
|
||||
const likesChangedFixture = {
|
||||
id: 280,
|
||||
@@ -680,87 +681,4 @@ acceptance("Topic stats update automatically", function () {
|
||||
"it updates the likes count on the topic stats"
|
||||
);
|
||||
});
|
||||
|
||||
const postsChangedFixture = {
|
||||
id: 280,
|
||||
type: "stats",
|
||||
posts_count: 999,
|
||||
last_posted_at: "2022-06-20T21:01:45.844Z",
|
||||
last_poster: {
|
||||
id: 1,
|
||||
username: "test",
|
||||
name: "Mr. Tester",
|
||||
avatar_template: "/images/d-logo-sketch-small.png",
|
||||
},
|
||||
};
|
||||
|
||||
test("Replies count updates automatically", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
|
||||
const repliesCountSelectors = "#post_1 .topic-map .replies .number";
|
||||
const oldRepliesCount = query(repliesCountSelectors).textContent;
|
||||
const expectedRepliesCount = (
|
||||
postsChangedFixture.posts_count - 1
|
||||
).toString();
|
||||
|
||||
// simulate the topic posts_count being changed
|
||||
await publishToMessageBus("/topic/280", postsChangedFixture);
|
||||
|
||||
assert.dom(repliesCountSelectors).hasText(expectedRepliesCount);
|
||||
assert.notEqual(
|
||||
oldRepliesCount,
|
||||
expectedRepliesCount,
|
||||
"it updates the replies count on the topic stats"
|
||||
);
|
||||
});
|
||||
|
||||
test("Last replier avatar updates automatically", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
const avatarSelectors = "#post_1 .topic-map .last-reply .avatar";
|
||||
const avatarImg = query(avatarSelectors);
|
||||
|
||||
const oldAvatarTitle = avatarImg.title;
|
||||
const oldAvatarSrc = avatarImg.src;
|
||||
const expectedAvatarTitle = postsChangedFixture.last_poster.name;
|
||||
const expectedAvatarSrc = postsChangedFixture.last_poster.avatar_template;
|
||||
|
||||
// simulate the topic posts_count being changed
|
||||
await publishToMessageBus("/topic/280", postsChangedFixture);
|
||||
|
||||
assert.dom(avatarSelectors).hasAttribute("title", expectedAvatarTitle);
|
||||
assert.notEqual(
|
||||
oldAvatarTitle,
|
||||
expectedAvatarTitle,
|
||||
"it updates the last poster avatar title on the topic stats"
|
||||
);
|
||||
|
||||
assert.dom(avatarSelectors).hasAttribute("src", expectedAvatarSrc);
|
||||
assert.notEqual(
|
||||
oldAvatarSrc,
|
||||
expectedAvatarSrc,
|
||||
"it updates the last poster avatar src on the topic stats"
|
||||
);
|
||||
});
|
||||
|
||||
test("Last replied at updates automatically", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
|
||||
const lastRepliedAtSelectors =
|
||||
"#post_1 .topic-map .last-reply .relative-date";
|
||||
const lastRepliedAtDisplay = query(lastRepliedAtSelectors);
|
||||
const oldTime = lastRepliedAtDisplay.dataset.time;
|
||||
const expectedTime = Date.parse(
|
||||
postsChangedFixture.last_posted_at
|
||||
).toString();
|
||||
|
||||
// simulate the topic posts_count being changed
|
||||
await publishToMessageBus("/topic/280", postsChangedFixture);
|
||||
|
||||
assert.dom(lastRepliedAtSelectors).hasAttribute("data-time", expectedTime);
|
||||
assert.notEqual(
|
||||
oldTime,
|
||||
expectedTime,
|
||||
"it updates the last posted time on the topic stats"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ acceptance("User Card", function (needs) {
|
||||
|
||||
test("opens and closes properly", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click(".topic-map__users-trigger");
|
||||
await click('a[data-user-card="charlie"]');
|
||||
|
||||
assert.dom(".user-card .card-content").exists();
|
||||
@@ -32,6 +33,7 @@ acceptance("User Card - Show Local Time", function (needs) {
|
||||
currentUser.user_option.timezone = "Australia/Brisbane";
|
||||
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click(".topic-map__users-trigger");
|
||||
await click('a[data-user-card="charlie"]');
|
||||
|
||||
assert
|
||||
@@ -98,6 +100,8 @@ acceptance("User Card - User Status", function (needs) {
|
||||
this.siteSettings.enable_user_status = true;
|
||||
|
||||
await visit("/t/internationalization-localization/280");
|
||||
|
||||
await click(".topic-map__users-trigger");
|
||||
await click('a[data-user-card="charlie"]');
|
||||
|
||||
assert.dom(".user-card .user-status").exists();
|
||||
@@ -107,6 +111,8 @@ acceptance("User Card - User Status", function (needs) {
|
||||
this.siteSettings.enable_user_status = false;
|
||||
|
||||
await visit("/t/internationalization-localization/280");
|
||||
|
||||
await click(".topic-map__users-trigger");
|
||||
await click('a[data-user-card="charlie"]');
|
||||
|
||||
assert.dom(".user-card .user-status").doesNotExist();
|
||||
|
||||
@@ -779,9 +779,13 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||
assert.dom(".topic-map").doesNotExist();
|
||||
});
|
||||
|
||||
test("topic map - few posts", async function (assert) {
|
||||
test("topic map - few participants", async function (assert) {
|
||||
const store = getOwner(this).lookup("service:store");
|
||||
const topic = store.createRecord("topic", { id: 123 });
|
||||
const topic = store.createRecord("topic", {
|
||||
id: 123,
|
||||
posts_count: 10,
|
||||
participant_count: 2,
|
||||
});
|
||||
topic.details.set("participants", [
|
||||
{ username: "eviltrout" },
|
||||
{ username: "codinghorror" },
|
||||
@@ -789,25 +793,28 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||
this.set("args", {
|
||||
topic,
|
||||
showTopicMap: true,
|
||||
topicPostsCount: 2,
|
||||
});
|
||||
|
||||
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
|
||||
assert.dom("li.avatars a.poster").doesNotExist();
|
||||
|
||||
await click("nav.buttons button");
|
||||
assert.dom(".topic-map-expanded a.poster").exists({ count: 2 });
|
||||
assert.dom(".topic-map__users-trigger").doesNotExist();
|
||||
assert.dom(".topic-map__users-list a.poster").exists({ count: 2 });
|
||||
});
|
||||
|
||||
test("topic map - participants", async function (assert) {
|
||||
const store = getOwner(this).lookup("service:store");
|
||||
const topic = store.createRecord("topic", { id: 123, posts_count: 10 });
|
||||
const topic = store.createRecord("topic", {
|
||||
id: 123,
|
||||
posts_count: 10,
|
||||
participant_count: 6,
|
||||
});
|
||||
topic.postStream.setProperties({ userFilters: ["sam", "codinghorror"] });
|
||||
topic.details.set("participants", [
|
||||
{ username: "eviltrout" },
|
||||
{ username: "codinghorror" },
|
||||
{ username: "sam" },
|
||||
{ username: "ZogStrIP" },
|
||||
{ username: "zogstrip" },
|
||||
{ username: "joffreyjaffeux" },
|
||||
{ username: "david" },
|
||||
]);
|
||||
|
||||
this.set("args", {
|
||||
@@ -816,12 +823,12 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||
});
|
||||
|
||||
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
|
||||
assert.dom("li.avatars a.poster").exists({ count: 3 });
|
||||
assert.dom(".topic-map__users-list a.poster").exists({ count: 5 });
|
||||
|
||||
await click("nav.buttons button");
|
||||
assert.dom("li.avatars a.poster").doesNotExist();
|
||||
assert.dom(".topic-map-expanded a.poster").exists({ count: 4 });
|
||||
assert.dom("a.poster.toggled").exists({ count: 2 });
|
||||
await click(".topic-map__users-trigger");
|
||||
assert
|
||||
.dom(".topic-map__users-content .topic-map__users-list a.poster")
|
||||
.exists({ count: 6 });
|
||||
});
|
||||
|
||||
test("topic map - links", async function (assert) {
|
||||
@@ -840,17 +847,12 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
|
||||
|
||||
assert.dom(".topic-map").exists({ count: 1 });
|
||||
assert.dom(".map.map-collapsed").exists({ count: 1 });
|
||||
assert.dom(".topic-map-expanded").doesNotExist();
|
||||
|
||||
await click("nav.buttons button");
|
||||
assert.dom(".map.map-collapsed").doesNotExist();
|
||||
assert.dom(".topic-map .d-icon-chevron-up").exists({ count: 1 });
|
||||
assert.dom(".topic-map-expanded").exists({ count: 1 });
|
||||
assert.dom(".topic-map-expanded .topic-link").exists({ count: 5 });
|
||||
|
||||
assert.dom(".topic-map__links-content").doesNotExist();
|
||||
await click(".topic-map__links-trigger");
|
||||
assert.dom(".topic-map__links-content").exists({ count: 1 });
|
||||
assert.dom(".topic-map__links-content .topic-link").exists({ count: 5 });
|
||||
await click(".link-summary button");
|
||||
assert.dom(".topic-map-expanded .topic-link").exists({ count: 6 });
|
||||
assert.dom(".topic-map__links-content .topic-link").exists({ count: 6 });
|
||||
});
|
||||
|
||||
test("topic map - no top reply summary", async function (assert) {
|
||||
@@ -860,23 +862,17 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||
|
||||
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
|
||||
|
||||
assert.dom(".toggle-summary .top-replies").doesNotExist();
|
||||
assert.dom(".summarization-buttons .top-replies").doesNotExist();
|
||||
});
|
||||
|
||||
test("topic map - has top replies summary", async function (assert) {
|
||||
const store = getOwner(this).lookup("service:store");
|
||||
const topic = store.createRecord("topic", { id: 123, has_summary: true });
|
||||
this.set("args", { topic, showTopicMap: true });
|
||||
this.set("showTopReplies", () => (this.summaryToggled = true));
|
||||
|
||||
await render(
|
||||
hbs`<MountWidget @widget="post" @args={{this.args}} @showTopReplies={{this.showTopReplies}} />`
|
||||
);
|
||||
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
|
||||
|
||||
assert.dom(".toggle-summary").exists({ count: 1 });
|
||||
|
||||
await click(".toggle-summary button");
|
||||
assert.ok(this.summaryToggled);
|
||||
assert.dom(".summarization-buttons .top-replies").exists({ count: 1 });
|
||||
});
|
||||
|
||||
test("pm map", async function (assert) {
|
||||
@@ -893,8 +889,8 @@ module("Integration | Component | Widget | post", function (hooks) {
|
||||
|
||||
await render(hbs`<MountWidget @widget="post" @args={{this.args}} />`);
|
||||
|
||||
assert.dom(".private-message-map").exists({ count: 1 });
|
||||
assert.dom(".private-message-map .user").exists({ count: 1 });
|
||||
assert.dom(".topic-map__private-message-map").exists({ count: 1 });
|
||||
assert.dom(".topic-map__private-message-map .user").exists({ count: 1 });
|
||||
});
|
||||
|
||||
test("post notice - with username", async function (assert) {
|
||||
|
||||
Reference in New Issue
Block a user