UX: Merge the simplified topic map ()

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:
Jan Cernik 2024-07-22 19:42:29 -03:00 committed by GitHub
parent 6039b513fe
commit a027ec4663
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1547 additions and 1020 deletions

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

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

@ -82,18 +82,23 @@
}
.topic-map {
margin-left: calc(var(--pm-padding) * -1);
border: none;
border-radius: var(--pm-border-radius);
padding: var(--pm-padding);
padding-block: var(--pm-padding);
padding-left: calc(
48px - var(--pm-padding)
); // 48px is the width of the avatar
section {
border: none;
background: var(--primary-very-low);
padding-inline: var(--pm-padding);
}
.map:not(.map-collapsed) {
.avatars {
margin: 0.5em 0;
}
.map {
padding-top: var(--pm-padding);
}
&__private-message-map {
padding-bottom: var(--pm-padding);
}
.participants {

@ -695,195 +695,9 @@ aside.quote {
position: relative;
}
.topic-map {
background: var(--primary-very-low);
border: 1px solid var(--primary-low);
border-top: none; // would cause double top border
.avatars {
display: flex;
flex-wrap: wrap;
align-items: center;
box-sizing: border-box;
a {
display: block;
position: relative;
}
.avatar {
width: 2.15em;
height: 2.15em;
}
.post-count {
position: absolute;
top: 0;
right: 0;
border-radius: 100px;
padding: 0.15em 0.4em 0.2em;
text-align: center;
font-weight: normal;
font-size: var(--font-down-2);
line-height: var(--line-height-small);
}
}
.avatar {
margin-right: 0.33em;
}
section {
border-top: 1px solid var(--primary-low);
}
ul {
display: flex;
flex-wrap: wrap;
margin: 0;
list-style: none;
}
h3 {
width: 100%;
margin-bottom: 0.33em;
color: var(--primary);
line-height: var(--line-height-large);
font-weight: normal;
font-size: var(--font-0);
}
h4 {
margin: 1px 0 2px 0;
color: var(--primary-med-or-secondary-med);
font-weight: normal;
font-size: var(--font-down-1);
line-height: var(--line-height-small);
}
span.domain {
font-size: var(--font-down-2);
color: var(--primary-med-or-secondary-med);
}
td {
vertical-align: top;
padding: 1px;
word-break: break-all;
}
.topic-links {
tbody {
border: none;
}
tr {
border: none;
}
.badge-notification {
margin: 1px 5px 2px 0;
}
}
.link-summary .btn {
width: 100%;
.d-icon {
color: var(--primary-high);
}
.discourse-no-touch & {
&:hover {
background: var(--primary-low);
}
}
}
.controls {
display: flex;
align-items: center;
.btn {
margin-right: 0.5em;
&:last-child {
margin: 0;
}
}
}
.participants {
display: flex;
flex-wrap: wrap;
align-items: center;
& + .controls {
margin-top: 0.5em;
}
&.hide-names .user {
.username,
.group-name {
display: none;
}
}
.user {
@include ellipsis;
border: 1px solid var(--primary-low);
border-radius: 0.25em;
padding: 0;
margin: 0.125em 0.25em 0.125em 0;
display: flex;
align-items: center;
height: 26px;
.user-link,
.group-link {
@include ellipsis;
color: var(--primary-high);
&:hover {
color: var(--primary);
}
}
.avatar,
.d-icon-users {
margin-left: 0.25em;
margin-right: 0.25em;
}
.username,
.group-name {
margin-right: 0.25em;
}
&:last-child {
margin-right: 0;
}
.remove-invited {
display: flex;
flex: 1 0 0px;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 0 var(--border-radius) var(--border-radius) 0;
padding-top: 0;
padding-bottom: 0;
height: calc(100% + 0.25em);
margin-right: -4px;
}
}
}
.add-remove-participant-btn {
.d-icon {
margin-left: 0.25em;
}
}
}
.topic-avatar,
.avatar-flair-preview,
.user-card-avatar,
.topic-map .poster,
.user-profile-avatar,
.user-image,
.latest-topic-list-item {
@ -932,38 +746,6 @@ aside.quote {
font-size: var(--font-up-4);
}
}
.topic-map .poster .avatar-flair {
right: 0;
background-size: 12px 12px;
width: 16px;
height: 16px;
bottom: -3px;
color: var(--primary);
&.rounded {
background-size: 12px 12px;
border-radius: 8px;
width: 16px;
height: 16px;
bottom: -2px;
right: 0;
}
.d-icon {
font-size: var(--font-down-2);
}
}
.map {
&:first-of-type {
display: flex;
.buttons {
margin-left: auto;
order: 15;
.btn {
height: 100%;
}
}
}
}
.topic-body {
// this is necessary for ANYTHING that extends past the right edge of

@ -88,7 +88,7 @@
grid-template-columns: auto;
.topic-navigation {
grid-area: posts;
grid-row: 2;
grid-row: 3;
width: auto;
}

@ -41,6 +41,7 @@
@import "tap-tile";
@import "time-input";
@import "time-shortcut-picker";
@import "topic-map";
@import "topic-query-filter";
@import "user-card";
@import "user-info";

@ -0,0 +1,500 @@
article {
// topic map under OP
.topic-map {
border-top: 1px solid var(--primary-low);
border-bottom: none;
}
}
.container.posts {
// topic map at bottom of topic
> .topic-map {
grid-area: posts;
grid-row: 2;
max-width: calc(
var(--topic-avatar-width) + var(--topic-body-width) +
(var(--topic-body-width-padding) * 2)
);
}
}
.topic-map {
// both topic maps
--chart-line-color: var(--tertiary);
--chart-point-color: var(--tertiary-medium);
--chart-prediction-color: var(--primary-low-mid);
border-bottom: 1px solid var(--primary-low);
box-sizing: border-box;
max-width: calc(
var(--topic-avatar-width) + var(--topic-body-width) +
(var(--topic-body-width-padding) * 2)
);
@include breakpoint(mobile-large) {
font-size: var(--font-down-1);
}
.--users-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
overflow: hidden;
height: 2em;
align-self: center;
flex: 1 2 0;
gap: 0.25em;
.avatar {
width: 2em;
height: 2em;
margin-right: 0;
}
.avatar-flair,
.post-count {
display: none;
}
}
&__stats {
display: flex;
flex-wrap: wrap;
margin: 0;
list-style: none;
}
a {
overflow-wrap: anywhere;
}
.fk-d-menu__content {
.fk-d-menu__inner-content,
.d-modal__container {
box-sizing: border-box;
max-height: 80dvh;
min-width: 20em;
width: 100%;
overflow: auto;
align-items: start;
overscroll-behavior: contain;
padding: 1.5em;
width: 100%;
flex-direction: column;
.desktop-view & {
@include breakpoint(mobile-large) {
min-width: unset;
max-width: 90dvw;
}
}
}
.loading-container {
width: 100%;
}
h3 {
font-weight: bold;
font-size: var(--font-up-1);
margin-top: -0.35em;
margin-bottom: 0.5em;
width: 100%;
}
}
&__contents {
padding: 0.5em 0 0.5em
calc(var(--topic-body-width-padding) + var(--topic-avatar-width));
@media screen and (max-width: 500px) {
padding: 0.5em 0;
}
.number {
font-size: var(--font-up-1);
line-height: 1.2;
}
.topic-map__stats {
gap: 0.75em;
&.--single-stat {
button {
flex-direction: row;
gap: 0.25em;
span {
font-size: var(--font-0);
color: var(--primary-700) !important;
min-width: unset;
overflow: visible;
}
}
}
.fk-d-menu__trigger {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0;
background: transparent;
min-width: 0;
.number {
color: var(--tertiary);
}
.topic-map__stat-label {
display: block;
min-width: 0;
font-size: var(--font-down-1);
color: var(--primary-medium);
width: 100%;
@include ellipsis;
}
}
}
}
&__additional-contents {
padding-left: calc(
var(--topic-body-width-padding) + var(--topic-avatar-width)
);
@media screen and (max-width: 500px) {
padding-left: 0;
}
}
.controls {
display: flex;
align-items: center;
.btn {
margin-right: 0.5em;
&:last-child {
margin: 0;
}
}
}
.participants {
// PMs
display: flex;
flex-wrap: wrap;
align-items: center;
& + .controls {
margin-top: 0.5em;
}
&.hide-names .user {
.username,
.group-name {
display: none;
}
}
.user {
@include ellipsis;
border: 1px solid var(--primary-low);
border-radius: 0.25em;
padding: 0;
margin: 0.125em 0.25em 0.125em 0;
display: flex;
align-items: center;
height: 26px;
.user-link,
.group-link {
@include ellipsis;
color: var(--primary-high);
&:hover {
color: var(--primary);
}
}
.avatar,
.d-icon-users {
margin-left: 0.25em;
margin-right: 0.25em;
}
.username,
.group-name {
margin-right: 0.25em;
}
&:last-child {
margin-right: 0;
}
.remove-invited {
display: flex;
flex: 1 0 0px;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 0 var(--border-radius) var(--border-radius) 0;
padding-top: 0;
padding-bottom: 0;
height: calc(100% + 0.25em);
margin-right: -4px;
}
}
}
.add-remove-participant-btn {
.d-icon {
margin-left: 0.25em;
}
}
&__buttons {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.75em;
}
.view-explainer {
color: var(--primary-700);
font-size: var(--font-down-1);
margin-top: 1em;
}
.estimated-read-time {
display: flex;
flex-direction: column-reverse;
align-items: end;
line-height: 1.2;
color: var(--primary-high);
white-space: nowrap;
span:first-child {
font-size: var(--font-down-1);
color: var(--primary-medium);
}
@media screen and (max-width: 475px) {
display: none;
}
}
}
.mobile-view {
.d-modal[class*="topic-map__"] {
.d-modal__body {
padding: 1em 1em 2em 1em;
h3 {
width: 100%;
font-weight: bold;
font-size: var(--font-up-2);
}
}
}
.topic-owner .onscreen-post {
padding-bottom: 0;
}
}
// DMenu popups
.topic-map__likes-content {
.fk-d-menu__inner-content,
.d-modal__body {
ul {
margin: 0;
padding: 0;
list-style-type: none;
li > a {
display: grid;
grid-template-areas: "user likes" "post post";
grid-template-columns: auto 1fr;
border-top: 1px solid var(--primary-low);
padding: 1em 0;
gap: 0.25em;
.discourse-no-touch & {
&:hover {
background: var(--primary-very-low);
box-shadow: -1em 0px 0px 0px var(--primary-very-low),
1em 0px 0px 0px var(--primary-very-low);
}
}
}
.like-section__user {
grid-area: user;
color: var(--primary-high);
justify-content: start;
display: flex;
align-items: start;
font-weight: bold;
gap: 0.5em;
img {
position: relative;
top: 0.2em;
}
}
.like-section__likes {
grid-area: likes;
display: flex;
align-items: start;
gap: 0.25em;
color: var(--primary-medium);
justify-content: end;
font-size: var(--font-0);
.d-icon {
font-size: var(--font-down-1);
color: var(--love);
position: relative;
top: 0.28em;
}
}
p {
grid-area: post;
overflow-wrap: anywhere;
color: var(--primary-high);
text-align: left;
margin: 0;
padding-left: 2em;
}
}
}
}
.topic-map__users-content {
.fk-d-menu__inner-content,
.d-modal__body {
.topic-map__users-list {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
}
.poster {
display: block;
position: relative;
img {
width: 2.25em;
height: 2.25em;
}
}
.post-count,
.avatar-flair {
position: absolute;
border-radius: 1em;
display: flex;
align-items: center;
justify-content: center;
}
.post-count {
top: -0.15em;
right: -0.25em;
padding: 0.15em 0.4em 0.2em;
font-size: var(--font-down-2);
line-height: var(--line-height-small);
}
.avatar-flair {
right: -0.25em;
bottom: -0.15em;
font-size: var(--font-down-3);
width: 1.5em;
height: 1.5em;
background-size: contain;
}
}
}
.topic-map__links-content {
.fk-d-menu__inner-content,
.d-modal__body {
.topic-links {
width: 100%;
tbody {
border: none;
}
tr {
border-top: 1px solid var(--primary-low);
border-bottom: none;
td:nth-of-type(2) {
padding-left: 0.5em;
}
}
}
a {
display: inline-block;
line-height: var(--line-height-medium);
}
td {
vertical-align: top;
padding: 0.5em 0;
}
span.domain {
font-size: var(--font-down-2);
color: var(--primary-medium);
}
.link-summary .btn {
width: 100%;
.d-icon {
color: var(--primary-high);
}
.discourse-no-touch & {
&:hover {
background: var(--primary-low);
}
}
}
}
}
.topic-map__views-content {
.fk-d-menu__inner-content,
.d-modal__body {
.topic-views {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
padding: 0.5em 1em 0;
&__wrapper {
display: flex;
width: 100%;
align-items: space-between;
}
&__count {
font-size: var(--font-up-4);
color: var(--primary-high);
}
&__date {
font-size: var(--font-down-1);
color: var(--primary-medium);
}
}
&:has(.topic-views) {
min-width: unset;
h3 {
text-align: center;
}
}
}
}

@ -375,76 +375,6 @@ pre.codeblock-buttons:hover {
}
}
.topic-map {
margin: 20px 0 20px var(--topic-body-width-padding);
.map {
.secondary {
text-align: center;
}
li {
float: left;
padding: 7px 10px;
&:last-of-type {
border-right: 0;
}
&:nth-child(3) {
text-align: center;
}
}
.number {
color: var(--primary-high);
}
.number,
.d-icon {
font-size: var(--font-up-2);
line-height: var(--line-height-medium);
}
button .d-icon,
button:hover .d-icon {
color: var(--primary-high);
}
.avatar a {
float: left;
}
.topic-map-post {
margin-top: 6px;
}
}
.avatars,
.links,
.information {
padding: 7px 10px 7px 10px;
color: var(--primary);
}
.buttons {
float: right;
.btn {
border: 0;
padding: 0 23px;
color: var(--primary-med-or-secondary-high);
background: var(--blend-primary-secondary-5);
border-left: 1px solid var(--primary-low);
border-top: 1px solid transparent;
&:hover {
color: var(--primary);
background: var(--primary-low);
}
&.collapsed {
padding-bottom: 1px;
}
.fa {
margin: 0;
font-size: var(--font-up-2);
line-height: 52px;
}
}
}
.toggle-summary .summary-box {
margin-top: 10px;
}
}
#topic-footer-buttons {
max-width: calc(
var(--topic-body-width) + (var(--topic-body-width-padding) * 2) +

@ -243,77 +243,6 @@ a.reply-to-tab {
clear: both;
}
.topic-map {
margin: 10px 0;
h4 {
line-height: var(--line-height-medium);
}
.user {
float: left;
margin-right: 10px;
}
.map-collapsed {
.secondary {
display: none;
}
}
.map {
li {
float: left;
padding: 7px 8px;
&:last-of-type {
border-right: 0;
}
}
.number {
line-height: var(--line-height-medium);
}
.number,
.d-icon {
color: var(--primary-high-or-secondary-low);
font-size: var(--font-up-1);
}
.avatar + a {
float: left;
}
}
li.avatars {
display: none;
}
.links,
.information,
.avatars {
padding: 10px;
color: var(--primary);
overflow: auto;
}
.information {
p {
margin: 0 0 10px 0;
}
}
.buttons {
.btn {
border: 0;
padding: 0 15px;
color: var(--primary-med-or-secondary-high);
background: var(--blend-primary-secondary-5);
border-left: 1px solid var(--primary-low);
.fa {
margin: 0;
font-size: var(--font-up-2);
line-height: 52px;
}
}
}
.toggle-summary {
.summarization-buttons {
flex-direction: column;
}
}
}
#topic-footer-buttons {
.d-icon-bookmark.bookmarked,
.d-icon-discourse-bookmark-clock.bookmarked {

@ -136,7 +136,7 @@ sub sub {
}
.container.posts .topic-navigation {
// better positioning for the docked progress bar on large screens using mobile view
// docked progress bar on large screens using mobile view
grid-area: posts;
grid-row: 2;
grid-row: 3; // after topic map
}

@ -171,10 +171,6 @@ html {
}
}
.topic-map h4 {
color: var(--primary);
}
.quote-controls,
.quote-controls .d-icon {
color: var(--primary-medium);
@ -203,10 +199,6 @@ html {
color: var(--primary-high);
}
.topic-map {
background: transparent;
}
// Post controls
.topic-admin-menu-button-container,

@ -2237,6 +2237,7 @@ en:
enable: "Show top replies"
disable: "Show All Posts"
short_label: "Top replies"
show_all_label: "Show all"
short_title: "Show this topic top replies: the most interesting posts as determined by the community"
deleted_filter:
@ -4135,6 +4136,17 @@ en:
clicks:
one: "%{count} click"
other: "%{count} clicks"
menu_titles:
replies: Most liked replies
views: Recent views
view_explainer: One view per unique visitor every 8 hours.
no_views: No view stats yet, check back later.
chart_error: Error rendering chart, please try again.
views: "Views"
predicted_views: "Predicted Views"
so_far: (so far)
read: read
minutes: min
post_links:
about: "expand more links for this post"
title:

@ -3,7 +3,8 @@
module PageObjects
module Components
class PrivateMessageMap < PageObjects::Components::Base
PRIVATE_MESSAGE_MAP_KLASS = ".private-message-map"
PRIVATE_MESSAGE_MAP_KLASS = ".topic-map__private-message-map"
def is_visible?
has_css?(PRIVATE_MESSAGE_MAP_KLASS)
end

@ -3,7 +3,7 @@
module PageObjects
module Components
class TopicMap < PageObjects::Components::Base
TOPIC_MAP_KLASS = ".topic-map"
TOPIC_MAP_KLASS = ".topic-map.--op"
def is_visible?
has_css?(TOPIC_MAP_KLASS)
@ -13,68 +13,53 @@ module PageObjects
has_no_css?(TOPIC_MAP_KLASS)
end
def is_collapsed?
has_css?("#{TOPIC_MAP_KLASS} .map-collapsed")
end
def expand
find("#{TOPIC_MAP_KLASS} .map-collapsed .btn").click if is_collapsed?
def has_no_users?
has_no_css?("#{TOPIC_MAP_KLASS} .topic-map__users-trigger")
end
def has_no_likes?
has_no_css?("#{TOPIC_MAP_KLASS} .likes")
has_no_css?("#{TOPIC_MAP_KLASS} .topic-map__likes-trigger")
end
def has_no_links?
has_no_css?("#{TOPIC_MAP_KLASS} .links")
has_no_css?("#{TOPIC_MAP_KLASS} .topic-map__links-trigger")
end
def users_count
find("#{TOPIC_MAP_KLASS} .users .number").text.to_i
end
def replies_count
find("#{TOPIC_MAP_KLASS} .replies .number").text.to_i
find("#{TOPIC_MAP_KLASS} .topic-map__users-trigger .number").text.to_i
end
def likes_count
find("#{TOPIC_MAP_KLASS} .likes .number").text.to_i
find("#{TOPIC_MAP_KLASS} .topic-map__likes-trigger .number").text.to_i
end
def links_count
find("#{TOPIC_MAP_KLASS} .links .number").text.to_i
find("#{TOPIC_MAP_KLASS} .topic-map__links-trigger .number").text.to_i
end
def views_count
find("#{TOPIC_MAP_KLASS} .views .number").text.to_i
end
def created_details
find("#{TOPIC_MAP_KLASS} .topic-map-post.created-at")
end
def created_relative_date
created_details.find(".relative-date").text
end
def last_reply_details
find("#{TOPIC_MAP_KLASS} .topic-map-post.last-reply")
end
def last_reply_relative_date
last_reply_details.find(".relative-date").text
find("#{TOPIC_MAP_KLASS} .topic-map__views-trigger .number").text.to_i
end
def avatars_details
find("#{TOPIC_MAP_KLASS} .map .avatars").all(".poster.trigger-user-card")
find("#{TOPIC_MAP_KLASS} .topic-map__users-list").all(".poster.trigger-user-card")
end
def expanded_map_avatars_details
find("#{TOPIC_MAP_KLASS} .topic-map-expanded .avatars").all(".poster.trigger-user-card")
def expanded_avatars_details
find("#{TOPIC_MAP_KLASS} .topic-map__users-trigger").click
find("#{TOPIC_MAP_KLASS} .topic-map__users-content").all(".poster.trigger-user-card")
end
def has_no_avatars_details_in_map?
has_no_css?("#{TOPIC_MAP_KLASS} .map .avatars")
has_no_css?("#{TOPIC_MAP_KLASS} .topic-map__users-list")
end
def has_bottom_map?
has_css?(".topic-map.--bottom")
end
def has_no_bottom_map?
has_no_css?(".topic-map.--bottom")
end
end
end

@ -2,7 +2,11 @@
describe "Topic Map - Private Message", type: :system do
fab!(:user) { Fabricate(:admin, refresh_auto_groups: true) }
fab!(:other_user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:other_user_1) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:other_user_2) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:other_user_3) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:other_user_4) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:other_user_5) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:last_post_user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:topic) do
Fabricate(
@ -11,7 +15,11 @@ describe "Topic Map - Private Message", type: :system do
user: user,
topic_allowed_users: [
Fabricate.build(:topic_allowed_user, user: user),
Fabricate.build(:topic_allowed_user, user: other_user),
Fabricate.build(:topic_allowed_user, user: other_user_1),
Fabricate.build(:topic_allowed_user, user: other_user_2),
Fabricate.build(:topic_allowed_user, user: other_user_3),
Fabricate.build(:topic_allowed_user, user: other_user_4),
Fabricate.build(:topic_allowed_user, user: other_user_5),
Fabricate.build(:topic_allowed_user, user: last_post_user),
],
)
@ -38,41 +46,35 @@ describe "Topic Map - Private Message", type: :system do
# topic map appears after OP
expect(topic_page).to have_topic_map
# created avatar display
expect(topic_map.created_details).to have_selector("img[src=\"#{avatar_url(user, 24)}\"]")
expect(topic_map.created_relative_date).to eq "1d"
# user count
expect(topic_map).to have_no_users
[other_user_1, other_user_2, other_user_3, other_user_4, other_user_5].each do |usr|
Fabricate(:post, topic: topic, user: usr, created_at: 1.day.ago)
end
page.refresh
expect(topic_map.users_count).to eq 6
# replies, user count
expect {
Fabricate(:post, topic: topic, user: user, created_at: 1.day.ago)
sign_in(last_post_user)
topic_page.visit_topic_and_open_composer(topic)
topic_page.send_reply("this is a cool-cat post") # fabricating posts doesn't update the last post details
topic_page.visit_topic(topic)
}.to change(topic_map, :replies_count).by(2).and change(topic_map, :users_count).by(1)
#last reply avatar display
expect(topic_map.last_reply_details).to have_selector(
"img[src=\"#{avatar_url(last_post_user, 24)}\"]",
)
expect(topic_map.last_reply_relative_date).to eq "1m"
}.to change(topic_map, :users_count).by(1)
# avatars details with post counts
2.times { Fabricate(:post, topic: topic) }
Fabricate(:post, user: user, topic: topic)
2.times { Fabricate(:post, user: user, topic: topic) }
Fabricate(:post, user: last_post_user, topic: topic)
page.refresh
avatars = topic_map.avatars_details
expect(avatars.length).to eq 3 # max no. of avatars in a collapsed map
expect(avatars[0]).to have_selector("img[src=\"#{avatar_url(user, 48)}\"]")
expect(avatars[0].find(".post-count").text).to eq "3"
expect(avatars[1]).to have_selector("img[src=\"#{avatar_url(last_post_user, 48)}\"]")
expect(avatars[1].find(".post-count").text).to eq "2"
expect(avatars[2]).to have_no_css(".post-count")
expect(avatars.length).to eq 5 # max no. of avatars in a collapsed map
topic_map.expand
expect(topic_map).to have_no_avatars_details_in_map
expect(topic_map.expanded_map_avatars_details.length).to eq 4
expanded_avatars = topic_map.expanded_avatars_details
expect(expanded_avatars[0]).to have_selector("img[src=\"#{avatar_url(user, 48)}\"]")
expect(expanded_avatars[0].find(".post-count").text).to eq "3"
expect(expanded_avatars[1]).to have_selector("img[src=\"#{avatar_url(last_post_user, 48)}\"]")
expect(expanded_avatars[1].find(".post-count").text).to eq "2"
expect(expanded_avatars[2]).to have_no_css(".post-count")
expect(expanded_avatars.length).to eq 7
# views count
# TODO (martin) Investigate flakiness
@ -84,8 +86,11 @@ describe "Topic Map - Private Message", type: :system do
# likes count
expect(topic_map).to have_no_likes
Fabricate(:post, topic: topic, like_count: 5)
page.refresh
expect(topic_map).to have_no_likes
topic_page.click_like_reaction_for(original_post)
expect(topic_map.likes_count).to eq 1
expect(topic_map.likes_count).to eq 6
end
it "has private message map that shows correct participants and allows editing of participant invites" do
@ -98,7 +103,17 @@ describe "Topic Map - Private Message", type: :system do
# participants' links and avatars
private_message_map
.participants_details
.zip([user, other_user, last_post_user]) do |details, usr|
.zip(
[
user,
other_user_1,
other_user_2,
other_user_3,
other_user_4,
other_user_5,
last_post_user,
],
) do |details, usr|
expect(details).to have_link(usr.username, href: "/u/#{usr.username}")
expect(details.find(".trigger-user-card")).to have_selector(
"img[src=\"#{avatar_url(usr, 24)}\"]",
@ -134,7 +149,7 @@ describe "Topic Map - Private Message", type: :system do
expect(private_message_map).to have_add_participants_button
private_message_map.click_add_participants_button
expect(private_message_invite_modal).to be_open
private_message_invite_modal.select_invitee(other_user)
private_message_invite_modal.select_invitee(other_user_1)
private_message_invite_modal.click_primary_button
expect(private_message_invite_modal).to have_invitee_already_exists_error
private_message_invite_modal.select_invitee(last_post_user)

@ -2,7 +2,7 @@
#
describe "Topic Map", type: :system do
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:topic) { Fabricate(:topic, user: user, created_at: 1.day.ago) }
fab!(:topic) { Fabricate(:topic, user: user, created_at: 2.day.ago) }
fab!(:original_post) { Fabricate(:post, topic: topic, user: user, created_at: 1.day.ago) }
fab!(:other_user) { Fabricate(:user, refresh_auto_groups: true) }
@ -22,45 +22,46 @@ describe "Topic Map", type: :system do
# topic map only appears after at least 1 reply
expect(topic_page).to have_no_topic_map
Fabricate(:post, topic: topic, created_at: 1.day.ago)
Fabricate(:post, topic: topic, created_at: 2.day.ago)
Fabricate(:post, topic: topic, created_at: 1.day.ago, like_count: 3)
2.times { Fabricate(:post, topic: topic, created_at: 1.day.ago, like_count: 1) }
page.refresh
expect(topic_page).to have_topic_map
expect(topic_map).to have_no_users
# created avatar display
expect(topic_map.created_details).to have_selector("img[src=\"#{avatar_url(user, 24)}\"]")
expect(topic_map.created_relative_date).to eq "1d"
Fabricate(:post, topic: topic, created_at: 1.day.ago)
page.refresh
expect(topic_map.users_count).to eq 6
# replies, user count
# user count
expect {
Fabricate(:post, topic: topic, user: user, created_at: 1.day.ago)
sign_in(last_post_user)
topic_page.visit_topic_and_open_composer(topic)
topic_page.send_reply("this is a cool-cat post") # fabricating posts doesn't update the last post details
topic_page.visit_topic(topic)
}.to change(topic_map, :replies_count).by(2).and change(topic_map, :users_count).by(1)
}.to change(topic_map, :users_count).by(1)
#last reply avatar display
expect(topic_map.last_reply_details).to have_selector(
"img[src=\"#{avatar_url(last_post_user, 24)}\"]",
)
expect(topic_map.last_reply_relative_date).to eq "1m"
# bottom map, avatars details with post counts
expect(topic_map).to have_no_bottom_map
# avatars details with post counts
Fabricate(:post, topic: topic)
Fabricate(:post, user: user, topic: topic)
Fabricate(:post, user: last_post_user, topic: topic)
page.refresh
avatars = topic_map.avatars_details
expect(avatars.length).to eq 3 # max no. of avatars in a collapsed map
expect(avatars[0]).to have_selector("img[src=\"#{avatar_url(user, 48)}\"]")
expect(avatars[0].find(".post-count").text).to eq "3"
expect(avatars[1]).to have_selector("img[src=\"#{avatar_url(last_post_user, 48)}\"]")
expect(avatars[1].find(".post-count").text).to eq "2"
expect(avatars[2]).to have_no_css(".post-count")
topic_map.expand
expect(topic_map).to have_no_avatars_details_in_map
expect(topic_map.expanded_map_avatars_details.length).to eq 4
expect(topic_map).to have_bottom_map
avatars = topic_map.avatars_details
expect(avatars.length).to eq 5 # max no. of avatars in a collapsed map
expanded_avatars = topic_map.expanded_avatars_details
expect(expanded_avatars[0]).to have_selector("img[src=\"#{avatar_url(user, 48)}\"]")
expect(expanded_avatars[0].find(".post-count").text).to eq "3"
expect(expanded_avatars[1]).to have_selector("img[src=\"#{avatar_url(last_post_user, 48)}\"]")
expect(expanded_avatars[1].find(".post-count").text).to eq "2"
expect(expanded_avatars[2]).to have_no_css(".post-count")
expect(expanded_avatars.length).to eq 8
# views count
# TODO (martin) Investigate flakiness
@ -73,6 +74,6 @@ describe "Topic Map", type: :system do
# likes count
# expect(topic_map).to have_no_likes
# topic_page.click_like_reaction_for(original_post)
# expect(topic_map.likes_count).to eq 1
# expect(topic_map.likes_count).to eq 6
end
end