UX: Several improvements for chat in nightmode (#2430)

* UX: Several improvements for chat in nightmode

* FIX: Some react refactoring

* FIX: Fixed failing react tests
This commit is contained in:
Tim Lange 2019-04-14 17:05:58 +02:00 committed by Ben Halpern
parent e2503ec1c2
commit 81c28886c8
12 changed files with 278 additions and 175 deletions

View file

@ -119,6 +119,7 @@
.chat__channeltypefilter{
padding: 4px 0px 4px;
width: calc(100% - 13px);
}
.chat__channeltypefilterbutton{
@ -137,6 +138,11 @@
background: lighten($green, 18%);
}
.chat__channeltypefilterbutton--inactive {
color: black;
color: var(--theme-container-color, black);
}
.chat_chatconfig{
position: absolute;
bottom: 0px;
@ -303,6 +309,8 @@
}
.activechatchannel__conversation{
border-top: 1px solid $outline-color;
border-top: var(--theme-container-border, 1px solid $outline-color);
order: 1;
display: flex;
flex-direction: column;
@ -321,7 +329,7 @@
font-weight: bold;
font-size:14px;
background: $light-gray;
background: var(--theme-background, $light-gray);
background: var(--theme-container-accent-background, $light-gray);
a {
color: $black;
color: var(--theme-color, $black);
@ -365,7 +373,7 @@
border-top: 1px solid $outline-color;
border-top: var(--theme-container-border, 1px solid $outline-color);
background: #ededed;
background: var(--theme-background, #ededed);
background: var(--theme-container-accent-background, #ededed);
width: 100%;
height: 70px;
}
@ -391,11 +399,12 @@
min-width: 45%;
// margin-left: -50%;
background: white;
background: var(--theme-container-accent-background, white);
// flex-basis: 80%;
z-index: 200;
position: relative;
box-sizing: border-box;
overflow-y: scroll;
overflow-y: auto;
max-width:96%;
@media screen and ( max-width: 426px ) {
padding: 10px;
@ -427,6 +436,7 @@
border: 0px;
font-size: 36px;
color: $medium-gray;
color: var(--theme-container-color, $medium-gray);
padding:0px;
background: transparent;
position: absolute;
@ -449,6 +459,7 @@
font-size:0.85em;
a{
color: $dark-gray;
color: var(--theme-container-color, $dark-gray);
}
.activechatchannel__activeArticleDetailsPath{
display: inline-block;
@ -495,6 +506,9 @@
border-style: initial;
border-color: initial;
border-image: initial;
&:first-child {
margin-left: 0;
}
}
}
@ -538,17 +552,20 @@
right:0px;
padding: 19px;
background: white;
background: var(--theme-container-accent-background, white);
z-index: 20;
border-top: 1px solid $light-medium-gray;
border-bottom: 1px solid $light-medium-gray;
border-right: 1px solid $light-medium-gray;
button {
border: 1px solid $light-medium-gray;
padding: 3px 15px;
border-radius: 3px;
margin-right: 15px;
width: 54px;
background: #f2f3f5;
background: var(--theme-container-background, #f2f3f5);
border-radius: 100px !important;
border-width: 0;
padding: 5px 10px;
&.active {
border: 2px solid $black;
padding: 2px 14px;
background-color:$green;
&.unicorn-reaction-button{
background-color:$purple;
@ -867,6 +884,12 @@
margin-left: 0px;
padding-left: 8px;
}
a h1 {
color: $black;
color: var(--theme-container-color, $black);
border-color: #0a0a0a !important;
border-color: var(--theme-container-color, #0a0a0a) !important;
}
}
.chatmessagebody__currentuser {

View file

@ -13,7 +13,13 @@
// channel users
.channeldetails__user {
a {
padding: 0 4px !important;
border-radius: 3px;
background: white;
}
padding: 2px 0;
margin-bottom: 10px;
.channeldetails__userprofileimage {
height: 25px;
width: 25px;

View file

@ -36,7 +36,8 @@ $ease-in-cubic : cubic-bezier(0.55, 0.055, 0.675, 0.19);
$ease-out-cubic : cubic-bezier(0.215, 0.61, 0.355, 1);
body.night-theme {
.on-page-nav-butt img, .icon-img, .dropdown-icon, .reaction-button:not(.reacted) img, .image-upload-button button, .icon-image {
.on-page-nav-butt img, .icon-img, .dropdown-icon, .reaction-button:not(.reacted) img, .image-upload-button button, .icon-image,
.chatchannels__config img, .external-link-img, .group-img {
-webkit-filter: invert(95%);
filter: invert(95%);
}

View file

@ -9,6 +9,7 @@ exports[`<Article /> should load article 1`] = `
>
<a
href="/princesscarolyn/your-approval-means-nothing-to-me-42640"
rel="noopener noreferrer"
target="_blank"
>
<span
@ -17,6 +18,8 @@ exports[`<Article /> should load article 1`] = `
/princesscarolyn/your-approval-means-nothing-to-me-42640
</span>
<img
alt="External Link Icon"
class="external-link-img"
src=""
/>
</a>
@ -83,6 +86,7 @@ exports[`<Article /> should load article 1`] = `
onClick={[Function]}
>
<img
data-category="like"
src=""
/>
</button>

View file

@ -34,6 +34,7 @@ exports[`<Channels /> expanded with chat channels should render and test snapsho
>
<img
alt="channel name 1"
class="group-img"
src=""
/>
</span>
@ -101,6 +102,7 @@ exports[`<Channels /> expanded with chat channels should render and test snapsho
>
<img
alt="group channel 3"
class="group-img"
src=""
/>
</span>

View file

@ -76,6 +76,7 @@ exports[`<Chat /> should load chat 1`] = `
>
<img
alt="channel name 1"
class="group-img"
src=""
/>
</span>
@ -143,6 +144,7 @@ exports[`<Chat /> should load chat 1`] = `
>
<img
alt="group channel 3"
class="group-img"
src=""
/>
</span>

View file

@ -70,6 +70,7 @@ exports[`<UserDetails /> for user1 should render and test snapshot 1`] = `
target="_blank"
>
<img
class="external-link-img"
src=""
style={
Object {
@ -237,6 +238,7 @@ exports[`<UserDetails /> for user2 should render and test snapshot 1`] = `
target="_blank"
>
<img
class="external-link-img"
src=""
style={
Object {

View file

@ -1,6 +1,6 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { shallow } from 'preact-render-spy';
import { deep } from 'preact-render-spy';
import fetch from 'jest-fetch-mock';
import Article from '../article';
@ -59,7 +59,7 @@ describe('<Article />', () => {
it('should have the proper elements, attributes and values', async () => {
await fetch.mockResponseOnce(sampleResponse);
const context = shallow(getArticle());
const context = deep(getArticle(), { depth: 2 });
await flushPromises();
// checks that article details are placed at their appropriate elements

View file

@ -1,5 +1,3 @@
import { h, Component } from 'preact';
import heartImage from 'images/emoji/emoji-one-heart.png';
import unicornImage from 'images/emoji/emoji-one-unicorn.png';
@ -13,54 +11,56 @@ export default class Article extends Component {
reactionCounts: [],
userReactions: [],
optimisticUserReaction: null,
}
};
}
componentDidMount() {
fetch("/reactions?article_id="+this.props.resource.id, {
fetch(`/reactions?article_id=${this.props.resource.id}`, {
Accept: 'application/json',
'Content-Type': 'application/json',
credentials: 'same-origin',
})
.then(response => response.json())
.then(this.displayReactions)
.catch(this.displayReactionsFailure);
.then(response => response.json())
.then(this.displayReactions)
.catch(this.displayReactionsFailure);
}
displayReactions = response => {
this.setState({userReactions:response.reactions})
}
this.setState({ userReactions: response.reactions });
};
displayReactionsFailure = response => {
console.log(response)
}
console.log(response);
};
handleNewReactionResponse = (response) => {
handleNewReactionResponse = response => {
let oldUserReactions = this.state.userReactions;
let foundReactions = oldUserReactions.filter(obj => {
return obj.category === response.category
})
const foundReactions = oldUserReactions.filter(obj => {
return obj.category === response.category;
});
if (foundReactions.length === 0 && response.result === 'create') {
oldUserReactions.push({category: response.category})
oldUserReactions.push({ category: response.category });
} else {
oldUserReactions = oldUserReactions.filter(obj => {
return obj.category != response.category
})
return obj.category != response.category;
});
}
this.setState({userReactions: oldUserReactions, optimisticUserReaction: null})
}
this.setState({
userReactions: oldUserReactions,
optimisticUserReaction: null,
});
};
handleNewReactionFailure = response => {
console.log(response)
}
console.log(response);
};
handleReactionClick = e => {
e.preventDefault();
const target = e.target;
console.log(target.dataset.category)
this.setState({optimisticUserReaction: target.dataset.category })
const article = this.props.resource
const { target } = e;
console.log(target.dataset.category);
this.setState({ optimisticUserReaction: target.dataset.category });
const article = this.props.resource;
fetch('/reactions', {
method: 'POST',
headers: {
@ -78,69 +78,118 @@ export default class Article extends Component {
.then(response => response.json())
.then(this.handleNewReactionResponse)
.catch(this.handleNewReactionFailure);
}
};
actionButton = (props) => {
const types = {
"heart": ["heart-reaction-button", "like", heartImage],
"unicorn": ["unicorn-reaction-button", "unicorn", unicornImage],
"readinglist": ["readinglist-reaction-button", "readinglist", bookmarkImage]
};
const curType = types[props.reaction];
return (
<button
className={`${curType[0]} ${props.reactedClass}`}
onClick={this.handleReactionClick}
data-category={curType[1]}
>
<img src={curType[2]} data-category={curType[1]} />
</button>
)
};
render() {
const article = this.props.resource;
let heartReactedClass = ''
let unicornReactedClass = ''
let bookmarkReactedClass = ''
const state = this.state;
state.userReactions.forEach((reaction) => {
if (reaction.category === 'like' || state.optimisticUserReaction === 'like') {
heartReactedClass = 'active'
let heartReactedClass = '';
let unicornReactedClass = '';
let bookmarkReactedClass = '';
const { state } = this;
state.userReactions.forEach(reaction => {
if (
reaction.category === 'like' ||
state.optimisticUserReaction === 'like'
) {
heartReactedClass = 'active';
}
if (reaction.category === 'unicorn' || state.optimisticUserReaction === 'unicorn') {
unicornReactedClass = 'active'
if (
reaction.category === 'unicorn' ||
state.optimisticUserReaction === 'unicorn'
) {
unicornReactedClass = 'active';
}
if (reaction.category === 'readinglist' || state.optimisticUserReaction === 'readinglist') {
bookmarkReactedClass = 'active'
if (
reaction.category === 'readinglist' ||
state.optimisticUserReaction === 'readinglist'
) {
bookmarkReactedClass = 'active';
}
});
let coverImage = '';
if (article.cover_image) {
coverImage = <section><div class="image image-final" style={{backgroundImage:`url(${article.cover_image}`}}></div></section>
coverImage = (
<section>
<div
className="image image-final"
style={{ backgroundImage: `url(${article.cover_image}` }}
/>
</section>
);
}
return (
<div className='activechatchannel__activeArticle'>
<div className="activechatchannel__activeArticleDetails">
<a href={article.path} target='_blank'>
<span className="activechatchannel__activeArticleDetailsPath">{article.path}</span>
<img src={openLink} />
</a>
</div>
<div className="container">
{coverImage}
<div className="title">
<h1>{article.title}</h1>
<h3>
<a href={'/'+article.user.username} className="author" data-content={'/users/'+article.user.id}>
<img className="profile-pic" src={article.user.profile_image_90} alt={article.user.username}/>
<span>{article.user.name} </span>
<span class="published-at"> | {article.readable_publish_date}</span>
</a>
</h3>
</div>
<div className="body">
<div dangerouslySetInnerHTML={{__html: article.body_html}} ></div>
</div>
</div>
<div className="activechatchannel__activeArticleActions">
<button className={'heart-reaction-button ' + heartReactedClass}
onClick={this.handleReactionClick} data-category='like' >
<img src={heartImage} />
</button>
<button className={'unicorn-reaction-button ' + unicornReactedClass}
onClick={this.handleReactionClick} data-category='unicorn'>
<img src={unicornImage} data-category='unicorn' />
</button>
<button className={'readinglist-reaction-button ' + bookmarkReactedClass}
onClick={this.handleReactionClick} data-category='readinglist'>
<img src={bookmarkImage} data-category='readinglist' />
</button>
</div>
</div>)
<div className="activechatchannel__activeArticle">
<div className="activechatchannel__activeArticleDetails">
<a href={article.path} target="_blank" rel="noopener noreferrer">
<span className="activechatchannel__activeArticleDetailsPath">
{article.path}
</span>
<img
alt="External Link Icon"
className="external-link-img"
src={openLink}
/>
</a>
</div>
<div className="container">
{coverImage}
<div className="title">
<h1>{article.title}</h1>
<h3>
<a
href={`/${article.user.username}`}
className="author"
data-content={`/users/${article.user.id}`}
>
<img
className="profile-pic"
src={article.user.profile_image_90}
alt={article.user.username}
/>
<span>
{article.user.name}
{' '}
</span>
<span className="published-at">
{' '}
|
{' '}
{article.readable_publish_date}
</span>
</a>
</h3>
</div>
<div className="body">
<div dangerouslySetInnerHTML={{ __html: article.body_html }} />
</div>
</div>
<div className="activechatchannel__activeArticleActions">
<this.actionButton reaction="heart" reactedClass={heartReactedClass} />
<this.actionButton reaction="unicorn" reactedClass={unicornReactedClass} />
<this.actionButton reaction="readinglist" reactedClass={bookmarkReactedClass} />
</div>
</div>
);
}
}

View file

@ -50,7 +50,11 @@ const Channels = ({
className="chatchanneltabindicatordirectimage"
/>
) : (
<img alt={channel.channel_name} src={GroupImage} />
<img
alt={channel.channel_name}
src={GroupImage}
className="group-img"
/>
);
let channelColor = 'transparent';
if (channel.channel_type === 'direct' && isActive) {

View file

@ -87,7 +87,7 @@ export default class Chat extends Component {
const filters =
this.state.channelTypeFilter === 'all'
? {}
: { filters: `channel_type:${ this.state.channelTypeFilter}` };
: { filters: `channel_type:${this.state.channelTypeFilter}` };
getChannels(
'',
this.state.activeChannelId,
@ -141,11 +141,11 @@ export default class Chat extends Component {
}
};
filterForActiveChannel = (channels, id) => channels.filter(channel => channel.id === parseInt(id))[0];
filterForActiveChannel = (channels, id) =>
channels.filter(channel => channel.id === parseInt(id))[0];
subscribePusher = channelName => {
if (this.state.subscribedPusherChannels.includes(channelName)) {
} else {
setupPusher(this.props.pusherKey, {
channelId: channelName,
@ -196,8 +196,7 @@ export default class Chat extends Component {
const channel = channels[0];
const channelSlug =
channel.channel_type === 'direct'
? `@${
channel.slug
? `@${channel.slug
.replace(`${window.currentUser.username}/`, '')
.replace(`/${window.currentUser.username}`, '')}`
: channel.slug;
@ -218,7 +217,9 @@ export default class Chat extends Component {
loadPaginatedChannels = channels => {
const currentChannels = this.state.chatChannels;
const currentChannelIds = currentChannels.map((channel, index) => channel.id);
const currentChannelIds = currentChannels.map(
(channel, index) => channel.id,
);
const newChannels = currentChannels;
channels.forEach((channel, index) => {
if (!currentChannelIds.includes(channel.id)) {
@ -343,14 +344,14 @@ export default class Chat extends Component {
) {
return;
}
const target = e.target;
const { target } = e;
if (target.scrollTop + target.offsetHeight + 1800 > target.scrollHeight) {
this.setState({ fetchingPaginatedChannels: true });
const filters =
this.state.channelTypeFilter === 'all'
? {}
: { filters: `channel_type:${ this.state.channelTypeFilter}` };
: { filters: `channel_type:${this.state.channelTypeFilter}` };
getChannels(
this.state.filterQuery,
this.state.activeChannelId,
@ -423,7 +424,7 @@ export default class Chat extends Component {
handleSwitchChannel = e => {
e.preventDefault();
let target = e.target;
let { target } = e;
if (!target.dataset.channelId) {
target = target.parentElement;
}
@ -457,7 +458,7 @@ export default class Chat extends Component {
showAlert: false,
});
this.setupChannel(id);
window.history.replaceState(null, null, `/connect/${ slug}`);
window.history.replaceState(null, null, `/connect/${slug}`);
if (!this.state.isMobileDevice) {
document.getElementById('messageform').focus();
}
@ -484,7 +485,7 @@ export default class Chat extends Component {
triggerNotificationRequest = e => {
const context = this;
Notification.requestPermission((permission) => {
Notification.requestPermission(permission => {
if (permission === 'granted') {
context.setState({ notificationsPermission: 'granted' });
setupNotifications();
@ -503,7 +504,7 @@ export default class Chat extends Component {
return;
}
const target = e.target;
const { target } = e;
if (target.dataset.content) {
e.preventDefault();
e.stopPropagation();
@ -520,7 +521,7 @@ export default class Chat extends Component {
type_of: 'loading-user',
};
getContent(
`/api/${ target.dataset.content}`,
`/api/${target.dataset.content}`,
this.setActiveContent,
null,
);
@ -529,7 +530,7 @@ export default class Chat extends Component {
type_of: 'loading-post',
};
getContent(
`/api/${ target.dataset.content}`,
`/api/${target.dataset.content}`,
this.setActiveContent,
null,
);
@ -590,7 +591,7 @@ export default class Chat extends Component {
channelTypeFilter: type,
fetchingPaginatedChannels: false,
});
const filters = type === 'all' ? {} : { filters: `channel_type:${ type}` };
const filters = type === 'all' ? {} : { filters: `channel_type:${type}` };
getChannels(
this.state.filterQuery,
null,
@ -631,37 +632,33 @@ export default class Chat extends Component {
return (
<div className="chatmessage" style={{ color: 'grey' }}>
<div className="chatmessage__body">
You and
You and
{' '}
<a
href={`/${ channelUsername}`}
style={{
color:
activeChannel.channel_users[channelUsername].darker_color,
}}
>
@
<a href={`/${channelUsername}`}>
@
{channelUsername}
</a>
{' '}
are connected because you both follow each other. All interactions
are
connected because you both follow each other. All interactions
{' '}
<em>
<b>must</b>
</em>
{' '}
abide by the
abide by the
{' '}
<a href="/code-of-conduct">code of conduct</a>
.
</div>
</div>
);
} if (activeChannel.channel_type === 'open') {
}
if (activeChannel.channel_type === 'open') {
return (
<div className="chatmessage" style={{ color: 'grey' }}>
<div className="chatmessage__body">
You have joined
You have joined
{' '}
{activeChannel.channel_name}
! All interactions
@ -670,7 +667,7 @@ export default class Chat extends Component {
<b>must</b>
</em>
{' '}
abide by the
abide by the
{' '}
<a href="/code-of-conduct">code of conduct</a>
.
@ -697,7 +694,7 @@ export default class Chat extends Component {
const filters =
this.state.channelTypeFilter === 'all'
? {}
: { filters: `channel_type:${ this.state.channelTypeFilter}` };
: { filters: `channel_type:${this.state.channelTypeFilter}` };
getChannels(
e.target.value,
null,
@ -714,7 +711,7 @@ export default class Chat extends Component {
renderChatChannels = () => {
if (this.state.showChannelsList) {
const notificationsPermission = this.state.notificationsPermission;
const { notificationsPermission } = this.state;
let notificationsButton = '';
let notificationsState = '';
let invitesButton = '';
@ -814,29 +811,26 @@ export default class Chat extends Component {
</div>
);
}
return (
<div className="chat__channels">
{notificationsButton}
<button
className="chat__channelstogglebutt"
onClick={this.toggleExpand}
style={{ width: '100%' }}
>
{'>'}
</button>
<Channels
incomingVideoCallChannelIds={
this.state.incomingVideoCallChannelIds
}
activeChannelId={this.state.activeChannelId}
chatChannels={this.state.chatChannels}
handleSwitchChannel={this.handleSwitchChannel}
expanded={this.state.expanded}
/>
{notificationsState}
</div>
);
return (
<div className="chat__channels">
{notificationsButton}
<button
className="chat__channelstogglebutt"
onClick={this.toggleExpand}
style={{ width: '100%' }}
>
{'>'}
</button>
<Channels
incomingVideoCallChannelIds={this.state.incomingVideoCallChannelIds}
activeChannelId={this.state.activeChannelId}
chatChannels={this.state.chatChannels}
handleSwitchChannel={this.handleSwitchChannel}
expanded={this.state.expanded}
/>
{notificationsState}
</div>
);
}
return '';
};
@ -888,7 +882,7 @@ export default class Chat extends Component {
.replace(`/${window.currentUser.username}`, '');
channelHeaderInner = (
<a
href={`/${ username}`}
href={`/${username}`}
onClick={this.triggerActiveContent}
data-content={`users/by_username?url=${username}`}
>
@ -899,7 +893,7 @@ export default class Chat extends Component {
} else {
channelHeaderInner = (
<a
href={`/${ currentChannel.adjusted_slug}`}
href={`/${currentChannel.adjusted_slug}`}
onClick={this.triggerActiveContent}
data-content="channel-details"
>
@ -948,11 +942,9 @@ export default class Chat extends Component {
}
return (
<div
className={
`chat chat--${
className={`chat chat--${
this.state.expanded ? 'expanded' : 'contracted'
}${detectIOSSafariClass}`
}
}${detectIOSSafariClass}`}
data-no-instant
>
{this.renderChatChannels()}

View file

@ -8,8 +8,7 @@ function blockChat(activeChannelId) {
formData.append('chat_id', activeChannelId);
formData.append('controller', 'chat_channels');
getCsrfToken()
.then(sendFetch('block-chat', formData));
getCsrfToken().then(sendFetch('block-chat', formData));
}
export default class UserDetails extends Component {
@ -44,6 +43,7 @@ export default class UserDetails extends Component {
socialIcons.push(
<a href={user.website_url} target="_blank">
<img
className="external-link-img"
src={websiteImage}
style={{ width: '30px', margin: '5px 15px 15px 0px' }}
/>
@ -95,36 +95,54 @@ export default class UserDetails extends Component {
Block User
</button>
<button onClick={() => {
var modal = document.getElementById("userdetails__reportabuse");
if (modal.style.display === "none") {
modal.style.display = "block";
window.location.href = `#userdetails__reportabuse`;
} else {
modal.style.display = "none";
window.location.href = `#`;
}
}}>
<button
onClick={() => {
const modal = document.getElementById('userdetails__reportabuse');
if (modal.style.display === 'none') {
modal.style.display = 'block';
window.location.href = `#userdetails__reportabuse`;
} else {
modal.style.display = 'none';
window.location.href = `#`;
}
}}
>
Report Abuse
</button>
</div>
<div id="userdetails__reportabuse" style="display:none">
<div className="userdetails__reportabuse">
<p>Reporting abuse will: </p>
<ul>
<li>close this chat and prevent this user from re-opening chat with you</li>
<li>give the DEV team your consent to read messages in this chat to understand your report</li>
<li>
close this chat and prevent this user from re-opening chat with
you
</li>
<li>
give the DEV team your consent to read messages in this chat to
understand your report
</li>
</ul>
<h5>Are you sure?</h5>
<a href="/report-abuse" onClick={() => {
blockChat(channelId);
}}>Yes</a>
<a class="no" onClick={() => {
document.getElementById("userdetails__reportabuse").style.display = "none";
window.location.href = `#`;
}}>No</a>
<a
href="/report-abuse"
onClick={() => {
blockChat(channelId);
}}
>
Yes
</a>
<a
className="no"
onClick={() => {
document.getElementById(
'userdetails__reportabuse',
).style.display = 'none';
window.location.href = `#`;
}}
>
No
</a>
</div>
</div>
</div>