Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions src/components/Comment/SlackMention.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
.Comment-SlackMention {
/* Trailing space below the row. The node's height is bound to the same
* value (it spans the content band above this padding), so they must match
* — hence the shared custom property. */
--slack-mention-trailing: 2.5rem;
position: relative;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4em;
/* Trailing space as padding (not margin) so the thread line below keeps
* running through it. Matches the ~40px rhythm between real comments,
* which carry their spacing internally rather than as a box gap. */
padding: 0 0 var( --slack-mention-trailing );
font-size: 0.85em;
line-height: 1.4;
color: var( --hm-medium-grey );
}

/* Keep the thread's vertical line running through the marker and its trailing
* space so it isn't visually broken. Colour mirrors .Comment:before's line
* (Comment.css hardcodes the same #F1F2EE; there is no token for it). */
.Comment-SlackMention:before {
position: absolute;
content: '';
background: #F1F2EE;
left: -41px;
top: 0;
bottom: 0;
width: 3px;
z-index: -1;
}

/* The Slack glyph sits on the line as a timeline node, masking it with a white
* background the way comment avatars do (matching Header.css). It spans the
* content band (above the trailing padding) so it stays centred on the text. */
.Comment-SlackMention__node {
position: absolute;
left: -47px;
top: 0;
bottom: var( --slack-mention-trailing );
display: flex;
align-items: center;
justify-content: center;
padding: 0 1px;
background: #fff;
}

.Comment-SlackMention__logo {
display: block;
}

.Comment-SlackMention__channel {
color: inherit;
font-weight: 600;
}

/* Only the linked (manual-share) channel underlines on hover; the auto-share
* channel is plain text, so it must not. */
a.Comment-SlackMention__channel:hover,
a.Comment-SlackMention__channel:focus {
text-decoration: underline;
}

/* Push the date to the right edge so it lines up with real comments' dates. */
.Comment-SlackMention__text {
margin-right: auto;
}

.Comment-SlackMention__date,
.Comment-SlackMention__date time {
color: inherit;
}

/* Match real comment dates: same size (.Comment-date is 0.75em of the larger
* header font ≈ 13.5px) and the same right-hand inset. */
.Comment-SlackMention__date {
font-size: 0.88em;
margin-right: 0.5em;
}

/* When a marker ends the thread, keep a short tail below the node — like a
* trailing comment — so the line stops short and the rounded dot below sits
* clear of the node's white mask (otherwise the cap is hidden behind it). */
.Comment-SlackMention:last-child {
padding-bottom: 1.5rem;
}

.Comment-SlackMention:last-child:before {
bottom: 1rem;
}

/* The node stays in the content band above the tail, so the logo aligns with
* the text and leaves the tail (line-end + dot) below it visible. */
.Comment-SlackMention:last-child .Comment-SlackMention__node {
bottom: 1.5rem;
}

/* Cap the line with the same rounded terminal dot a trailing comment gets
* (mirrors .Comment:last-child:after). */
.Comment-SlackMention:last-child:after {
position: absolute;
content: '';
background: #F1F2EE;
left: -43.5px;
bottom: calc( 1rem - 4.5px );
width: 9px;
height: 9px;
z-index: -1;
border-radius: 50%;
}

@media ( max-width: 600px ) {
.Comment-SlackMention:last-child:after {
left: -23px;
}

.Comment-SlackMention:before {
left: -20px;
}

.Comment-SlackMention__node {
left: -26px;
}
}
133 changes: 133 additions & 0 deletions src/components/Comment/SlackMention.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';

import FormattedDate from '../FormattedDate';
import AuthorLink from '../Message/AuthorLink';

import './SlackMention.css';

/*
* A de-emphasised marker in the comment stream noting that the post was
* shared into a Slack discussion. Written by the Human bot as a
* `slack_mention` comment; rendered as a quiet, single-line row rather than a
* full comment — no avatar, actions, replies, or threading. The Slack glyph
* sits on the thread's vertical line as a node so the line is not broken.
*/

function SlackLogo() {
return (
<svg
aria-hidden="true"
className="Comment-SlackMention__logo"
height="14"
viewBox="0 0 122.8 122.8"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z" fill="#E01E5A" />
<path d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z" fill="#36C5F0" />
<path d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z" fill="#2EB67D" />
<path d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z" fill="#ECB22E" />
</svg>
);
}

export default function SlackMention( { comment } ) {
const slack = comment.slack || {};
const isAuto = slack.source === 'auto';
// Only public channels are named; private channels, DMs, and group DMs are
// anonymised so an org-readable post never discloses where it was shared.
// Fail closed to 'unknown' (never infer 'public' from a channel name).
const visibility = slack.visibility || 'unknown';

const channelName = slack.channel_name
? `#${ slack.channel_name.replace( /^#/, '' ) }`
: '';

// The channel is named only when public — gating on visibility (not source
// or mere name-presence) keeps it fail-closed and consistent with the
// server. `isAuto` only chooses the wording below. An auto marker that
// names its channel always has visibility 'public', so this still names it.
const namesChannel = channelName && visibility === 'public';

// The named channel element (linked when we have a permalink). Null for
// anonymised contexts.
const channelEl = namesChannel
? ( slack.permalink ? (
<a
className="Comment-SlackMention__channel"
href={ slack.permalink }
rel="nofollow noopener noreferrer"
target="_blank"
>
{ channelName }
</a>
) : (
<span className="Comment-SlackMention__channel">{ channelName }</span>
) )
: null;

// Public manual shares are attributed to the sharer as a linked @username
// (opening their H2 profile) when the H2 side resolved one.
const sharer = {
id: slack.shared_by_id,
name: slack.shared_by,
};
const authorEl = ( slack.shared_by_username && slack.shared_by_id ) ? (
<AuthorLink user={ sharer } withHovercard={ false }>
@{ slack.shared_by_username }
</AuthorLink>
) : null;

// Build the row text, interleaving the (optional) sharer and channel links.
let body;
if ( isAuto && channelEl ) {
body = <Fragment>Auto-posted to { channelEl } on Slack</Fragment>;
} else if ( visibility === 'public' && channelEl ) {
body = authorEl
? <Fragment>Shared by { authorEl } in { channelEl } on Slack</Fragment>
: <Fragment>Shared in { channelEl } on Slack</Fragment>;
} else if ( visibility === 'private' ) {
body = 'Shared in a private channel on Slack';
} else if ( visibility === 'im' ) {
body = 'Shared in a DM on Slack';
} else if ( visibility === 'mpim' ) {
body = 'Shared in a group DM on Slack';
} else {
body = 'Shared in Slack';
}

return (
<div
className="Comment-SlackMention"
id={ `comment-${ comment.id }` }
>
<span className="Comment-SlackMention__node">
<SlackLogo />
</span>
<span className="Comment-SlackMention__text">
{ body }
</span>
<span className="Comment-SlackMention__date">
<FormattedDate date={ comment.date_gmt + 'Z' } />
</span>
</div>
);
}

SlackMention.propTypes = {
comment: PropTypes.shape( {
id: PropTypes.number,
type: PropTypes.string,
date_gmt: PropTypes.string,
slack: PropTypes.shape( {
channel_name: PropTypes.string,
permalink: PropTypes.string,
source: PropTypes.string,
visibility: PropTypes.string,
shared_by: PropTypes.string,
shared_by_username: PropTypes.string,
shared_by_id: PropTypes.number,
} ),
} ).isRequired,
};
25 changes: 18 additions & 7 deletions src/components/CommentsList.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { Component } from 'react';
import { Comment as CommentShape, Post } from '../shapes';

import Comment from './Comment';
import SlackMention from './Comment/SlackMention';

import './CommentsList.css';

Expand All @@ -12,13 +13,23 @@ export default class CommentsList extends Component {
return (
<div className="CommentsList">
{ this.props.comments.slice().sort( ( a, b ) => a.date < b.date ? -1 : 1 ).map( comment => (
<Comment
key={ comment.id }
comment={ comment }
comments={ this.props.allComments }
parentPost={ this.props.post }
onDidCreateComment={ this.props.onDidCreateComment }
/>
// "Shared in Slack" markers render as a quiet leaf row and
// must skip Comment's withUser/withSingle HOCs (they need
// neither an author fetch nor a single-comment load).
comment.type === 'slack_mention' ? (
<SlackMention
key={ comment.id }
comment={ comment }
/>
) : (
<Comment
key={ comment.id }
comment={ comment }
comments={ this.props.allComments }
parentPost={ this.props.post }
onDidCreateComment={ this.props.onDidCreateComment }
/>
)
) ) }
{ this.props.children }
</div>
Expand Down
12 changes: 10 additions & 2 deletions src/components/Post/Comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,18 @@ export default withArchive(
props => {
const { post } = props;

comments.registerArchive( post.id, {
// Distinct archive id + opt-in flag so only this stream view is widened
// to include slack_mention markers. Post/Summary shares this same
// `stream:${post.id}` archive (so the count stays consistent after a
// reply) but filters markers out of its count client-side. The
// recent-comments sidebar uses a separate author-scoped query and never
// opts in, so it stays markers-free.
const archiveId = `stream:${ post.id }`;
comments.registerArchive( archiveId, {
post: post.id,
per_page: 100,
slack_markers: 1,
} );
return post.id;
return archiveId;
}
)( PostComments );
21 changes: 16 additions & 5 deletions src/components/Post/Summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ function Summary( props ) {

const continueReadingMessage = `Continue reading (${ _n( 'word', 'words', post.content.count ) })`;

const people = comments ? uniq( comments.map( comment => comment.author ) ).filter( Boolean ) : [];
// The archive is shared with the comment stream and so includes
// `slack_mention` markers; exclude them from the count and avatar pile so
// they never read as human comments.
const realComments = comments ? comments.filter( comment => comment.type === 'comment' ) : [];

const people = uniq( realComments.map( comment => comment.author ) ).filter( Boolean );

const peopleClass = [
'Post-Summary-people',
Expand All @@ -52,9 +57,9 @@ function Summary( props ) {
) }
</Button>

{ ( ! loadingComments && comments && comments.length > 0 ) && (
{ ( ! loadingComments && realComments.length > 0 ) && (
<div className="Post-Summary-comments">
<span>{ _n( 'comment', 'comments', comments.length ) }</span>
<span>{ _n( 'comment', 'comments', realComments.length ) }</span>
<ul className={ peopleClass }>
{ people.slice( 0, 8 ).map( person => (
<li key={ person }>
Expand All @@ -78,11 +83,17 @@ export default withArchive(
props => {
const { post } = props;

comments.registerArchive( post.id, {
// Share the comment stream's widened archive (same id + slack_markers
// flag) so the count stays consistent with the stream after a reply and
// the post's comments are fetched once. Markers are filtered out of the
// count above.
const archiveId = `stream:${ post.id }`;
comments.registerArchive( archiveId, {
post: post.id,
per_page: 100,
slack_markers: 1,
} );
return post.id;
return archiveId;
},
{
mapDataToProps: data => ( {
Expand Down
Loading