Add optionnal desktop notification

This commit is contained in:
Jeremie Pardou-Piquemal 2020-04-29 21:22:11 +02:00 committed by Jérémie Pardou-Piquemal
parent d475a148b9
commit f01f995d9f
20 changed files with 738 additions and 85 deletions

View File

@ -15,6 +15,10 @@ export const toggleSoundEnabled = payload => async (dispatch) => {
dispatch({ type: 'TOGGLE_SOUND_ENABLED', payload }) dispatch({ type: 'TOGGLE_SOUND_ENABLED', payload })
} }
export const toggleNotificationEnabled = payload => async (dispatch) => {
dispatch({ type: 'TOGGLE_NOTIFICATION_ENABLED', payload })
}
export const toggleSocketConnected = payload => async (dispatch) => { export const toggleSocketConnected = payload => async (dispatch) => {
dispatch({ type: 'TOGGLE_SOCKET_CONNECTED', payload }) dispatch({ type: 'TOGGLE_SOCKET_CONNECTED', payload })
} }

View File

@ -11,8 +11,6 @@ import Settings from 'components/Settings'
import Welcome from 'components/Welcome' import Welcome from 'components/Welcome'
import RoomLocked from 'components/RoomLocked' import RoomLocked from 'components/RoomLocked'
import { X, AlertCircle } from 'react-feather' import { X, AlertCircle } from 'react-feather'
import Tinycon from 'tinycon'
import beepFile from 'audio/beep.mp3'
import classNames from 'classnames' import classNames from 'classnames'
import ActivityList from './ActivityList' import ActivityList from './ActivityList'
@ -81,16 +79,6 @@ class Home extends Component {
componentDidMount() { componentDidMount() {
this.bindEvents() this.bindEvents()
this.beep = window.Audio && new window.Audio(beepFile)
}
componentWillReceiveProps(nextProps) {
Tinycon.setBubble(nextProps.faviconCount)
if (nextProps.faviconCount !== 0 && nextProps.faviconCount !== this.props.faviconCount && this.props.soundIsEnabled) {
this.beep.play()
}
} }
getModal() { getModal() {
@ -108,7 +96,16 @@ class Home extends Component {
} }
case 'Settings': case 'Settings':
return { return {
component: <Settings roomId={this.props.roomId} toggleSoundEnabled={this.props.toggleSoundEnabled} soundIsEnabled={this.props.soundIsEnabled} setLanguage={this.props.setLanguage} language={this.props.language} translations={this.props.translations} />, component: <Settings
roomId={this.props.roomId}
toggleSoundEnabled={this.props.toggleSoundEnabled}
soundIsEnabled={this.props.soundIsEnabled}
toggleNotificationEnabled={this.props.toggleNotificationEnabled}
notificationIsEnabled={this.props.notificationIsEnabled}
setLanguage={this.props.setLanguage}
language={this.props.language}
translations={this.props.translations}
/>,
title: this.props.translations.settingsHeader, title: this.props.translations.settingsHeader,
} }
case 'Welcome': case 'Welcome':
@ -251,6 +248,8 @@ Home.propTypes = {
faviconCount: PropTypes.number.isRequired, faviconCount: PropTypes.number.isRequired,
soundIsEnabled: PropTypes.bool.isRequired, soundIsEnabled: PropTypes.bool.isRequired,
toggleSoundEnabled: PropTypes.func.isRequired, toggleSoundEnabled: PropTypes.func.isRequired,
notificationIsEnabled: PropTypes.bool.isRequired,
toggleNotificationEnabled: PropTypes.func.isRequired,
toggleSocketConnected: PropTypes.func.isRequired, toggleSocketConnected: PropTypes.func.isRequired,
socketConnected: PropTypes.bool.isRequired, socketConnected: PropTypes.bool.isRequired,
sendUnencryptedMessage: PropTypes.func.isRequired, sendUnencryptedMessage: PropTypes.func.isRequired,

View File

@ -36,6 +36,7 @@ jest.mock('utils/crypto', () => {
}); });
}); });
test('Home component is displaying', async () => { test('Home component is displaying', async () => {
const { asFragment } = render( const { asFragment } = render(
<Provider store={store}> <Provider store={store}>
@ -60,6 +61,8 @@ test('Home component is displaying', async () => {
socketConnected={false} socketConnected={false}
toggleSoundEnabled={() => {}} toggleSoundEnabled={() => {}}
soundIsEnabled={false} soundIsEnabled={false}
toggleNotificationEnabled={() => {}}
notificationIsEnabled={false}
faviconCount={0} faviconCount={0}
toggleWindowFocus={() => {}} toggleWindowFocus={() => {}}
closeModal={() => {}} closeModal={() => {}}

View File

@ -0,0 +1,69 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { notify, beep } from 'utils/notifications';
import Tinycon from 'tinycon';
const mapStateToProps = state => {
return {
activities: state.activities.items,
unreadMessageCount: state.app.unreadMessageCount,
windowIsFocused: state.app.windowIsFocused,
soundIsEnabled: state.app.soundIsEnabled,
notificationIsEnabled: state.app.notificationIsEnabled,
room: state.room,
};
};
const WithNewMessageNotification = WrappedComponent => {
return connect(mapStateToProps)(
class WithNotificationHOC extends Component {
state = { lastMessage: null, unreadMessageCount: 0 };
static getDerivedStateFromProps(nextProps, prevState) {
const {
room: { id: roomId },
activities,
notificationIsEnabled,
soundIsEnabled,
unreadMessageCount,
windowIsFocused,
} = nextProps;
if (activities.length === 0) {
return null;
}
const lastMessage = activities[activities.length - 1];
const { username, text } = lastMessage;
if (lastMessage !== prevState.lastMessage && !windowIsFocused) {
const title = `Message from ${username} (${roomId})`;
if (notificationIsEnabled) notify(title, text);
if (soundIsEnabled) beep.play();
}
if (unreadMessageCount !== prevState.unreadMessageCount) {
Tinycon.setBubble(unreadMessageCount);
}
return { lastMessage, unreadMessageCount };
}
render() {
// Filter props
const {
room,
activities,
notificationIsEnabled,
soundIsEnabled,
unreadMessageCount,
windowIsFocused,
...rest
} = this.props;
return <WrappedComponent {...rest} />;
}
},
);
};
export default WithNewMessageNotification;

View File

@ -0,0 +1,299 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Connected Home component should display 1`] = `
<DocumentFragment>
<div
class="styles h-100"
>
<div
class="nav-container"
>
<div
class="alert-banner"
>
<span
class="icon"
>
<svg
fill="none"
height="15"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="15"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
x2="12"
y1="8"
y2="12"
/>
<line
x1="12"
x2="12"
y1="16"
y2="16"
/>
</svg>
</span>
<span>
Disconnected
</span>
</div>
<nav
class="navbar navbar-expand-md navbar-dark"
>
<div
class="meta"
>
<img
alt="Darkwire"
class="logo"
src="logo.png"
/>
<button
class="btn btn-plain btn-link clipboard-trigger room-id ellipsis"
data-clipboard-text="http://localhost/"
data-placement="bottom"
data-toggle="tooltip"
title="Copied"
>
/
</button>
<span
class="lock-room-container"
>
<button
class="lock-room btn btn-link btn-plain"
data-placement="bottom"
data-toggle="tooltip"
title="You must be the owner to lock or unlock the room"
>
<svg
class="muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="11"
rx="2"
ry="2"
width="18"
x="3"
y="11"
/>
<path
d="M7 11V7a5 5 0 0 1 9.9-1"
/>
</svg>
</button>
</span>
<div
class="dropdown members-dropdown"
>
<a
class="dropdown__trigger "
>
<button
class="btn btn-link btn-plain members-action"
title="Users"
>
<svg
class="users-icon"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"
/>
<circle
cx="9"
cy="7"
r="4"
/>
<path
d="M23 21v-2a4 4 0 0 0-3-3.87"
/>
<path
d="M16 3.13a4 4 0 0 1 0 7.75"
/>
</svg>
</button>
<span>
0
</span>
</a>
<div
class="dropdown__content "
>
<ul
class="plain"
/>
</div>
</div>
</div>
<button
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
class="navbar-toggler"
data-target="#navbarSupportedContent"
data-toggle="collapse"
type="button"
>
<span
class="navbar-toggler-icon"
/>
</button>
<div
class="collapse navbar-collapse"
id="navbarSupportedContent"
>
<ul
class="navbar-nav ml-auto"
>
<li
class="nav-item"
>
<button
class="btn btn-plain nav-link"
target="blank"
>
<svg
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
x2="12"
y1="8"
y2="16"
/>
<line
x1="8"
x2="16"
y1="12"
y2="12"
/>
</svg>
<span>
New Room
</span>
</button>
</li>
<li
class=" nav-item"
>
<button
class="btn btn-plain nav-link"
>
<svg
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="3"
/>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<span>
Settings
</span>
</button>
</li>
<li
class="nav-item"
>
<button
class="btn btn-plain nav-link"
>
<svg
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
x2="12"
y1="16"
y2="12"
/>
<line
x1="12"
x2="12"
y1="8"
y2="8"
/>
</svg>
<span>
About
</span>
</button>
</li>
</ul>
</div>
</nav>
</div>
</div>
</DocumentFragment>
`;

View File

@ -7,12 +7,14 @@ import {
closeModal, closeModal,
toggleWindowFocus, toggleWindowFocus,
toggleSoundEnabled, toggleSoundEnabled,
toggleNotificationEnabled,
toggleSocketConnected, toggleSocketConnected,
receiveUnencryptedMessage, receiveUnencryptedMessage,
sendUnencryptedMessage, sendUnencryptedMessage,
sendEncryptedMessage, sendEncryptedMessage,
setLanguage setLanguage
} from 'actions' } from 'actions'
import WithNewMessageNotification from './WithNewMessageNotification'
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const me = state.room.members.find(m => m.id === state.user.id) const me = state.room.members.find(m => m.id === state.user.id)
@ -30,6 +32,7 @@ const mapStateToProps = (state) => {
iAmOwner: Boolean(me && me.isOwner), iAmOwner: Boolean(me && me.isOwner),
faviconCount: state.app.unreadMessageCount, faviconCount: state.app.unreadMessageCount,
soundIsEnabled: state.app.soundIsEnabled, soundIsEnabled: state.app.soundIsEnabled,
notificationIsEnabled: state.app.notificationIsEnabled,
socketConnected: state.app.socketConnected, socketConnected: state.app.socketConnected,
language: state.app.language, language: state.app.language,
translations: state.app.translations, translations: state.app.translations,
@ -43,6 +46,7 @@ const mapDispatchToProps = {
closeModal, closeModal,
toggleWindowFocus, toggleWindowFocus,
toggleSoundEnabled, toggleSoundEnabled,
toggleNotificationEnabled,
toggleSocketConnected, toggleSocketConnected,
receiveUnencryptedMessage, receiveUnencryptedMessage,
sendUnencryptedMessage, sendUnencryptedMessage,
@ -50,7 +54,7 @@ const mapDispatchToProps = {
setLanguage setLanguage
} }
export default connect( export default WithNewMessageNotification(connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(Home) )(Home))

View File

@ -0,0 +1,161 @@
import React from 'react';
import { render } from '@testing-library/react';
import ConnectedHome from '.';
import { Provider } from 'react-redux';
import configureStore from 'store';
import { toggleWindowFocus, toggleNotificationEnabled, toggleSoundEnabled } from 'actions/app';
import { receiveEncryptedMessage } from 'actions/encrypted_messages';
import { notify, beep } from 'utils/notifications';
import Tinycon from 'tinycon';
import Modal from 'react-modal';
const store = configureStore();
jest.useFakeTimers();
// We don't test activity list here
jest.mock('./ActivityList', () => {
return jest.fn().mockReturnValue(null);
});
jest.mock('react-modal'); // Cant load modal without root app element
jest.mock('utils/socket', () => {
// Avoid exception
return {
connect: jest.fn().mockImplementation(() => {
return {
on: jest.fn(),
emit: jest.fn(),
};
}),
getSocket: jest.fn().mockImplementation(() => {
return {
on: jest.fn(),
emit: jest.fn(),
};
}),
};
});
jest.mock('shortid', () => {
// Avoid exception
return {
generate: jest.fn().mockImplementation(() => {
return 'shortidgenerated';
}),
};
});
jest.mock('utils/crypto', () => {
// Need window.crytpo.subtle
return jest.fn().mockImplementation(() => {
return {
createEncryptDecryptKeys: () => {
return {
privateKey: { n: 'private' },
publicKey: { n: 'public' },
};
},
exportKey: () => {
return { n: 'exportedKey' };
},
};
});
});
jest.mock('utils/message', () => {
return {
process: jest.fn(async (payload, state) => ({
...payload,
payload: { payload: 'text', username: 'sender', text: 'new message' },
})),
};
});
jest.mock('utils/notifications', () => {
return {
notify: jest.fn(),
beep: { play: jest.fn() },
};
});
jest.mock('tinycon', () => {
return {
setBubble: jest.fn(),
};
});
describe('Connected Home component', () => {
it('should display', () => {
const { asFragment } = render(
<Provider store={store}>
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" />
</Provider>,
);
expect(asFragment()).toMatchSnapshot();
});
it('should send notifications', async () => {
Modal.prototype.getSnapshotBeforeUpdate = jest.fn().mockReturnValue(null);
const { rerender } = render(
<Provider store={store}>
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" roomId={'testId'} />
</Provider>,
);
// Test with window focused
await receiveEncryptedMessage({
type: 'TEXT_MESSAGE',
payload: {},
})(store.dispatch, store.getState);
rerender(
<Provider store={store}>
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" roomId={'testId'} />
</Provider>,
);
expect(store.getState().app.unreadMessageCount).toBe(0);
expect(notify).not.toHaveBeenCalled();
expect(beep.play).not.toHaveBeenCalled();
expect(Tinycon.setBubble).not.toHaveBeenCalled();
// Test with window unfocused
await toggleWindowFocus(false)(store.dispatch);
await receiveEncryptedMessage({
type: 'TEXT_MESSAGE',
payload: {},
})(store.dispatch, store.getState);
rerender(
<Provider store={store}>
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" roomId={'testId'} />
</Provider>,
);
expect(store.getState().app.unreadMessageCount).toBe(1);
expect(notify).toHaveBeenLastCalledWith('Message from sender ()', 'new message');
expect(beep.play).toHaveBeenLastCalledWith();
expect(Tinycon.setBubble).toHaveBeenLastCalledWith(1);
// Test with sound and notification disabled
await toggleNotificationEnabled(false)(store.dispatch);
await toggleSoundEnabled(false)(store.dispatch);
await receiveEncryptedMessage({
type: 'TEXT_MESSAGE',
payload: {},
})(store.dispatch, store.getState);
rerender(
<Provider store={store}>
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" roomId={'testId'} />
</Provider>,
);
expect(store.getState().app.unreadMessageCount).toBe(2);
expect(notify).toHaveBeenCalledTimes(1);
expect(beep.play).toHaveBeenCalledTimes(1);
expect(Tinycon.setBubble).toHaveBeenLastCalledWith(2);
});
});

View File

@ -1,32 +1,32 @@
import React from 'react'; import React from 'react';
import { render, fireEvent } from '@testing-library/react'; import { render, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import configureStore from 'store';
import Settings from '.'; import Settings from '.';
const store = configureStore();
const mockTranslations = { const mockTranslations = {
sound: 'soundCheck', sound: 'soundCheck',
}; };
jest.mock('components/T', () => {
return jest.fn().mockImplementation(({ path }) => {
return mockTranslations[path] || 'default';
});
});
// To avoid missing provider
jest.mock('components/T');
jest.mock('components/RoomLink'); jest.mock('components/RoomLink');
describe('Settings component', () => { describe('Settings component', () => {
it('should display', async () => { it('should display', async () => {
const { asFragment } = render( const { asFragment } = render(
<Settings <Provider store={store}>
soundIsEnabled={true} <Settings
toggleSoundEnabled={() => {}} soundIsEnabled={true}
roomId="roomId" toggleSoundEnabled={() => {}}
setLanguage={() => {}} notificationIsEnabled={true}
translations={mockTranslations} toggleNotificationEnabled={() => {}}
/>, roomId="roomId"
setLanguage={() => {}}
translations={{}}
/>
</Provider>,
); );
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
@ -34,33 +34,62 @@ describe('Settings component', () => {
it('should toggle sound', async () => { it('should toggle sound', async () => {
const toggleSound = jest.fn(); const toggleSound = jest.fn();
const { getAllByText } = render( const { getByText } = render(
<Settings <Provider store={store}>
soundIsEnabled={true} <Settings
toggleSoundEnabled={toggleSound} soundIsEnabled={true}
roomId="roomId" toggleSoundEnabled={toggleSound}
setLanguage={() => {}} notificationIsEnabled={true}
translations={mockTranslations} toggleNotificationEnabled={() => {}}
/>, roomId="roomId"
setLanguage={() => {}}
translations={{}}
/>
</Provider>,
); );
//console.log(getAllByText(mockTranslations.sound)[1]); //console.log(getAllByText(mockTranslations.sound)[1]);
fireEvent.click(getAllByText(mockTranslations.sound)[1]); fireEvent.click(getByText('Sound'));
expect(toggleSound).toHaveBeenCalledWith(false); expect(toggleSound).toHaveBeenCalledWith(false);
}); });
it('should toggle notifications', async () => {
const toggleNotifications = jest.fn();
const { getByText } = render(
<Provider store={store}>
<Settings
soundIsEnabled={true}
toggleSoundEnabled={() => {}}
notificationIsEnabled={true}
toggleNotificationEnabled={toggleNotifications}
roomId="roomId"
setLanguage={() => {}}
translations={{}}
/>
</Provider>,
);
fireEvent.click(getByText('Desktop Notification'));
expect(toggleNotifications).toHaveBeenCalledWith(false);
});
it('should change lang', async () => { it('should change lang', async () => {
const changeLang = jest.fn(); const changeLang = jest.fn();
const { getByDisplayValue } = render( const { getByDisplayValue } = render(
<Settings <Provider store={store}>
soundIsEnabled={true} <Settings
toggleSoundEnabled={() => {}} soundIsEnabled={true}
roomId="roomId" toggleSoundEnabled={() => {}}
setLanguage={changeLang} notificationIsEnabled={true}
translations={{}} toggleNotificationEnabled={() => {}}
/>, roomId="roomId"
setLanguage={changeLang}
translations={{}}
/>
</Provider>,
); );
fireEvent.change(getByDisplayValue('English'), { target: { value: 'de' } }); fireEvent.change(getByDisplayValue('English'), { target: { value: 'de' } });

View File

@ -7,7 +7,7 @@ exports[`Settings component should display 1`] = `
> >
<section> <section>
<h4> <h4>
soundCheck New message notification
</h4> </h4>
<form> <form>
<div <div
@ -23,7 +23,23 @@ exports[`Settings component should display 1`] = `
id="sound-control" id="sound-control"
type="checkbox" type="checkbox"
/> />
soundCheck Sound
</label>
</div>
<div
class="form-check"
>
<label
class="form-check-label"
for="notif-control"
>
<input
checked=""
class="form-check-input"
id="notif-control"
type="checkbox"
/>
Desktop Notification
</label> </label>
</div> </div>
</form> </form>
@ -32,14 +48,14 @@ exports[`Settings component should display 1`] = `
<h4 <h4
class="mb-3" class="mb-3"
> >
default This room
</h4> </h4>
</section> </section>
<section> <section>
<h4 <h4
class="mb-3" class="mb-3"
> >
default Language
</h4> </h4>
<p> <p>
<a <a
@ -47,7 +63,7 @@ exports[`Settings component should display 1`] = `
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
> >
default Help us translate Darkwire!
</a> </a>
</p> </p>
<div <div
@ -96,26 +112,26 @@ exports[`Settings component should display 1`] = `
</section> </section>
<section> <section>
<h4> <h4>
default Room Ownership
</h4> </h4>
<p> <p>
default The person who created the room is the room owner and has special privileges, like the ability to lock and unlock the room. If the owner leaves the room, the second person to join assumes ownership. If they leave, the third person becomes owner, and so on. The room owner has a star icon next to their username in the participants dropdown.
</p> </p>
</section> </section>
<section> <section>
<h4> <h4>
default Lock Room
</h4> </h4>
<p> <p>
default If you are the room owner, you can lock and unlock the room by clicking the lock icon in the nav bar. When a room is locked, no other participants will be able to join.
</p> </p>
</section> </section>
<section> <section>
<h4> <h4>
default Slash Commands
</h4> </h4>
<p> <p>
default The following slash commands are available:
</p> </p>
<ul> <ul>
<li> <li>
@ -123,7 +139,7 @@ exports[`Settings component should display 1`] = `
<span <span
class="text-muted" class="text-muted"
> >
default changes username
</span> </span>
</li> </li>
<li> <li>
@ -131,7 +147,7 @@ exports[`Settings component should display 1`] = `
<span <span
class="text-muted" class="text-muted"
> >
default performs an action
</span> </span>
</li> </li>
<li> <li>
@ -139,7 +155,7 @@ exports[`Settings component should display 1`] = `
<span <span
class="text-muted" class="text-muted"
> >
default clears your message history
</span> </span>
</li> </li>
<li> <li>
@ -147,7 +163,7 @@ exports[`Settings component should display 1`] = `
<span <span
class="text-muted" class="text-muted"
> >
default lists all commands
</span> </span>
</li> </li>
</ul> </ul>

View File

@ -11,6 +11,10 @@ class Settings extends Component {
this.props.toggleSoundEnabled(!this.props.soundIsEnabled) this.props.toggleSoundEnabled(!this.props.soundIsEnabled)
} }
handleNotificationToggle() {
this.props.toggleNotificationEnabled(!this.props.notificationIsEnabled)
}
handleLanguageChange(evt) { handleLanguageChange(evt) {
const language = evt.target.value; const language = evt.target.value;
Cookie.set('language', language); Cookie.set('language', language);
@ -21,7 +25,7 @@ class Settings extends Component {
return ( return (
<div className={styles}> <div className={styles}>
<section> <section>
<h4><T path='sound'/></h4> <h4><T path='newMessageNotification'/></h4>
<form> <form>
<div className="form-check"> <div className="form-check">
<label className="form-check-label" htmlFor="sound-control"> <label className="form-check-label" htmlFor="sound-control">
@ -29,8 +33,15 @@ class Settings extends Component {
<T path='sound'/> <T path='sound'/>
</label> </label>
</div> </div>
<div className="form-check">
<label className="form-check-label" htmlFor="notif-control">
<input id="notif-control" onChange={this.handleNotificationToggle.bind(this)} className="form-check-input" type="checkbox" checked={this.props.notificationIsEnabled} />
<T path='desktopNotification'/>
</label>
</div>
</form> </form>
</section> </section>
<section> <section>
<h4 className='mb-3'><T path='copyRoomHeader'/></h4> <h4 className='mb-3'><T path='copyRoomHeader'/></h4>
<RoomLink roomId={this.props.roomId} translations={this.props.translations} /> <RoomLink roomId={this.props.roomId} translations={this.props.translations} />
@ -78,6 +89,8 @@ class Settings extends Component {
Settings.propTypes = { Settings.propTypes = {
soundIsEnabled: PropTypes.bool.isRequired, soundIsEnabled: PropTypes.bool.isRequired,
toggleSoundEnabled: PropTypes.func.isRequired, toggleSoundEnabled: PropTypes.func.isRequired,
notificationIsEnabled: PropTypes.bool.isRequired,
toggleNotificationEnabled: PropTypes.func.isRequired,
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
setLanguage: PropTypes.func.isRequired, setLanguage: PropTypes.func.isRequired,
translations: PropTypes.object.isRequired, translations: PropTypes.object.isRequired,

View File

@ -22,6 +22,8 @@
"slashCommandsHeader": "Slash-Befehle", "slashCommandsHeader": "Slash-Befehle",
"slashCommandsText": "Die folgenden Schrägstrichbefehle sind verfügbar:", "slashCommandsText": "Die folgenden Schrägstrichbefehle sind verfügbar:",
"sound": "Klingen", "sound": "Klingen",
"newMessageNotification": "New message notification",
"desktopNotification": "Desktop Notification",
"typePlaceholder": "Tippen Sie hier", "typePlaceholder": "Tippen Sie hier",
"unlockedRoom": "{username} hat den Raum freigeschaltet", "unlockedRoom": "{username} hat den Raum freigeschaltet",
"userJoined": "{username} ist beigetreten", "userJoined": "{username} ist beigetreten",
@ -30,4 +32,4 @@
"welcomeHeader": "Willkommen bei Darkwire v2.0", "welcomeHeader": "Willkommen bei Darkwire v2.0",
"welcomeModalCTA": "OK", "welcomeModalCTA": "OK",
"helpTranslate": "Hilf uns, Darkwire zu übersetzen!" "helpTranslate": "Hilf uns, Darkwire zu übersetzen!"
} }

View File

@ -25,14 +25,11 @@
"lockRoomText": "If you are the room owner, you can lock and unlock the room by clicking the lock icon in the nav bar. When a room is locked, no other participants will be able to join.", "lockRoomText": "If you are the room owner, you can lock and unlock the room by clicking the lock icon in the nav bar. When a room is locked, no other participants will be able to join.",
"slashCommandsHeader": "Slash Commands", "slashCommandsHeader": "Slash Commands",
"slashCommandsText": "The following slash commands are available:", "slashCommandsText": "The following slash commands are available:",
"slashCommandsBullets": [ "slashCommandsBullets": ["changes username", "performs an action", "clears your message history", "lists all commands"],
"changes username",
"performs an action",
"clears your message history",
"lists all commands"
],
"sound": "Sound", "sound": "Sound",
"newMessageNotification": "New message notification",
"desktopNotification": "Desktop Notification",
"welcomeModalCTA": "Ok", "welcomeModalCTA": "Ok",
"lockedRoomHeader": "This room is locked", "lockedRoomHeader": "This room is locked",
"helpTranslate": "Help us translate Darkwire!" "helpTranslate": "Help us translate Darkwire!"
} }

View File

@ -25,13 +25,10 @@
"lockRoomText": "Si vous êtes le propriétaire du salon, vous pouvez le verrouiller et le déverrouiller en cliquant sur l'icône de cadenas située dans la barre de navigation. Quand un salon est verrouillé, aucun autre participant ne peut rejoindre.", "lockRoomText": "Si vous êtes le propriétaire du salon, vous pouvez le verrouiller et le déverrouiller en cliquant sur l'icône de cadenas située dans la barre de navigation. Quand un salon est verrouillé, aucun autre participant ne peut rejoindre.",
"slashCommandsHeader": "Commandes", "slashCommandsHeader": "Commandes",
"slashCommandsText": "Les commandes suivantes sont disponibles :", "slashCommandsText": "Les commandes suivantes sont disponibles :",
"slashCommandsBullets": [ "slashCommandsBullets": ["changer de pseudo", "effectuer une action", "effacer votre historique de messages", "lister toutes les commandes"],
"changer de pseudo",
"effectuer une action",
"effacer votre historique de messages",
"lister toutes les commandes"
],
"sound": "Son", "sound": "Son",
"newMessageNotification": "Notification lors d'un nouveau message",
"desktopNotification": "Notification Système",
"welcomeModalCTA": "Ok", "welcomeModalCTA": "Ok",
"lockedRoomHeader": "Ce salon est verrouillé", "lockedRoomHeader": "Ce salon est verrouillé",
"helpTranslate": "Aidez-nous à traduire Darkwire!" "helpTranslate": "Aidez-nous à traduire Darkwire!"

View File

@ -22,6 +22,8 @@
"slashCommandsHeader": "Comandi", "slashCommandsHeader": "Comandi",
"slashCommandsText": "Sono disponibili i seguenti comandi:", "slashCommandsText": "Sono disponibili i seguenti comandi:",
"sound": "Suono", "sound": "Suono",
"newMessageNotification": "New message notification",
"desktopNotification": "Desktop Notification",
"typePlaceholder": "Digitare qui", "typePlaceholder": "Digitare qui",
"unlockedRoom": "{username} ha aperto la stanza", "unlockedRoom": "{username} ha aperto la stanza",
"userJoined": "{username} si è unito", "userJoined": "{username} si è unito",

View File

@ -22,6 +22,8 @@
"slashCommandsHeader": "Slash-opdrachten", "slashCommandsHeader": "Slash-opdrachten",
"slashCommandsText": "De volgende slash-opdrachten zijn beschikbaar:", "slashCommandsText": "De volgende slash-opdrachten zijn beschikbaar:",
"sound": "Geluid", "sound": "Geluid",
"newMessageNotification": "New message notification",
"desktopNotification": "Desktop Notification",
"typePlaceholder": "Typ hier", "typePlaceholder": "Typ hier",
"unlockedRoom": "{username} heeft de kamer ontgrendeld", "unlockedRoom": "{username} heeft de kamer ontgrendeld",
"userJoined": "{username} is lid geworden", "userJoined": "{username} is lid geworden",
@ -30,4 +32,4 @@
"welcomeHeader": "Welkom bij Darkwire v2.0", "welcomeHeader": "Welkom bij Darkwire v2.0",
"welcomeModalCTA": "OK", "welcomeModalCTA": "OK",
"helpTranslate": "Help ons Darkwire te vertalen!" "helpTranslate": "Help ons Darkwire te vertalen!"
} }

View File

@ -25,13 +25,10 @@
"lockRoomText": "Se sètz lo proprietari de la sala, podètz clavar e desclavar en clicar licòna del cadenat de la barra de navigacion. Quand una sala es clavada, cap de participant pòt pas la rejónher .", "lockRoomText": "Se sètz lo proprietari de la sala, podètz clavar e desclavar en clicar licòna del cadenat de la barra de navigacion. Quand una sala es clavada, cap de participant pòt pas la rejónher .",
"slashCommandsHeader": "Comandas", "slashCommandsHeader": "Comandas",
"slashCommandsText": "Las comandas seguentas son disponiblas:", "slashCommandsText": "Las comandas seguentas son disponiblas:",
"slashCommandsBullets": [ "slashCommandsBullets": ["càmbia descais-nom", "realiza una accion", "escafa listoric de conversacion", "lista totas las comandas"],
"càmbia descais-nom",
"realiza una accion",
"escafa listoric de conversacion",
"lista totas las comandas"
],
"sound": "Son", "sound": "Son",
"newMessageNotification": "New message notification",
"desktopNotification": "Desktop Notification",
"welcomeModalCTA": "Dacòrdi", "welcomeModalCTA": "Dacòrdi",
"lockedRoomHeader": "Aquesta sala es clavada", "lockedRoomHeader": "Aquesta sala es clavada",
"helpTranslate": "Ajudatz-nos a traduire Darkwire!" "helpTranslate": "Ajudatz-nos a traduire Darkwire!"

View File

@ -22,6 +22,8 @@
"slashCommandsHeader": "斜线命令", "slashCommandsHeader": "斜线命令",
"slashCommandsText": "可以使用以下斜杠命令:", "slashCommandsText": "可以使用以下斜杠命令:",
"sound": "声音", "sound": "声音",
"newMessageNotification": "New message notification",
"desktopNotification": "Desktop Notification",
"typePlaceholder": "在此输入", "typePlaceholder": "在此输入",
"unlockedRoom": "{username} 解锁了房间", "unlockedRoom": "{username} 解锁了房间",
"userJoined": "{username} 已加入", "userJoined": "{username} 已加入",
@ -30,4 +32,4 @@
"welcomeHeader": "欢迎来到Darkwire v2.0", "welcomeHeader": "欢迎来到Darkwire v2.0",
"welcomeModalCTA": "好", "welcomeModalCTA": "好",
"helpTranslate": "帮助我们翻译Darkwire" "helpTranslate": "帮助我们翻译Darkwire"
} }

View File

@ -1,5 +1,5 @@
import Cookie from 'js-cookie'; import Cookie from 'js-cookie';
import {getTranslations} from 'i18n'; import { getTranslations } from 'i18n';
const language = Cookie.get('language') || navigator.language || 'en'; const language = Cookie.get('language') || navigator.language || 'en';
@ -9,6 +9,7 @@ const initialState = {
windowIsFocused: true, windowIsFocused: true,
unreadMessageCount: 0, unreadMessageCount: 0,
soundIsEnabled: true, soundIsEnabled: true,
notificationIsEnabled: true,
socketConnected: false, socketConnected: false,
language, language,
translations: getTranslations(language) translations: getTranslations(language)
@ -47,6 +48,11 @@ const app = (state = initialState, action) => {
...state, ...state,
soundIsEnabled: action.payload, soundIsEnabled: action.payload,
} }
case 'TOGGLE_NOTIFICATION_ENABLED':
return {
...state,
notificationIsEnabled: action.payload,
}
case 'TOGGLE_SOCKET_CONNECTED': case 'TOGGLE_SOCKET_CONNECTED':
return { return {
...state, ...state,

View File

@ -14,6 +14,7 @@ describe('App reducer', () => {
modalComponent: null, modalComponent: null,
scrolledToBottom: true, scrolledToBottom: true,
socketConnected: false, socketConnected: false,
notificationIsEnabled: true,
soundIsEnabled: true, soundIsEnabled: true,
translations: { path: 'test' }, translations: { path: 'test' },
unreadMessageCount: 0, unreadMessageCount: 0,

View File

@ -0,0 +1,50 @@
import beepFile from 'audio/beep.mp3'
const showNotification = (title, message, avatarUrl) => {
const notifBody = {
body: message,
tag: 'darkwire',
silent: true, // we play our own sounds
};
const notification = new Notification(title, notifBody);
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// The tab has become visible so clear the now-stale Notification.
notification.close();
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
};
// Focus window on click
notification.onclick = function () {
window.focus();
notification.close();
};
document.addEventListener('visibilitychange', handleVisibilityChange);
};
export const notify = (title, content) => {
if (!('Notification' in window)) {
alert('This browser does not support desktop notification');
}
// Let's check whether notification permissions have already been granted
else if (Notification.permission === 'granted') {
// If it's okay let's create a notification
showNotification(title, content);
}
// Otherwise, we need to ask the user for permission
else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(function (permission) {
// If the user accepts, let's create a notification
if (permission === 'granted') {
showNotification(title, content);
}
});
}
};
export const beep = (window.Audio && new window.Audio(beepFile)) || { play: () => { }}
export default { notify, beep };