diff --git a/src/components/Comment/SlackMention.css b/src/components/Comment/SlackMention.css new file mode 100644 index 00000000..f422844a --- /dev/null +++ b/src/components/Comment/SlackMention.css @@ -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; + } +} diff --git a/src/components/Comment/SlackMention.js b/src/components/Comment/SlackMention.js new file mode 100644 index 00000000..6adbcf8c --- /dev/null +++ b/src/components/Comment/SlackMention.js @@ -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 ( + + ); +} + +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 ? ( + + { channelName } + + ) : ( + { channelName } + ) ) + : 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 ) ? ( + + @{ slack.shared_by_username } + + ) : null; + + // Build the row text, interleaving the (optional) sharer and channel links. + let body; + if ( isAuto && channelEl ) { + body = Auto-posted to { channelEl } on Slack; + } else if ( visibility === 'public' && channelEl ) { + body = authorEl + ? Shared by { authorEl } in { channelEl } on Slack + : Shared in { channelEl } on Slack; + } 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 ( +
+ + + + + { body } + + + + +
+ ); +} + +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, +}; diff --git a/src/components/CommentsList.js b/src/components/CommentsList.js index f3bd3039..35e6d925 100644 --- a/src/components/CommentsList.js +++ b/src/components/CommentsList.js @@ -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'; @@ -12,13 +13,23 @@ export default class CommentsList extends Component { return (
{ this.props.comments.slice().sort( ( a, b ) => a.date < b.date ? -1 : 1 ).map( comment => ( - + // "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' ? ( + + ) : ( + + ) ) ) } { this.props.children }
diff --git a/src/components/Post/Comments.js b/src/components/Post/Comments.js index bdffc027..55e485e2 100644 --- a/src/components/Post/Comments.js +++ b/src/components/Post/Comments.js @@ -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 ); diff --git a/src/components/Post/Summary.js b/src/components/Post/Summary.js index 352ebd84..563f6ee8 100644 --- a/src/components/Post/Summary.js +++ b/src/components/Post/Summary.js @@ -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', @@ -52,9 +57,9 @@ function Summary( props ) { ) } - { ( ! loadingComments && comments && comments.length > 0 ) && ( + { ( ! loadingComments && realComments.length > 0 ) && (
- { _n( 'comment', 'comments', comments.length ) } + { _n( 'comment', 'comments', realComments.length ) }