diff --git a/res/css/views/settings/_Notifications.scss b/res/css/views/settings/_Notifications.scss index d6c0b5dbeb..773ea055df 100644 --- a/res/css/views/settings/_Notifications.scss +++ b/res/css/views/settings/_Notifications.scss @@ -71,3 +71,26 @@ limitations under the License. .mx_UserNotifSettings_notifTable .mx_Spinner { position: absolute; } + +.mx_NotificationSound_soundUpload { + display: none; +} + +.mx_NotificationSound_browse { + color: $accent-color; + border: 1px solid $accent-color; + background-color: transparent; +} + +.mx_NotificationSound_save { + margin-left: 5px; + color: white; + background-color: $accent-color; +} + +.mx_NotificationSound_resetSound { + margin-top: 5px; + color: white; + border: $warning-color; + background-color: $warning-color; +} \ No newline at end of file diff --git a/src/Notifier.js b/src/Notifier.js index 2322311769..0b0a5f6990 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -100,10 +100,55 @@ const Notifier = { } }, - _playAudioNotification: function(ev, room) { - const e = document.getElementById("messageAudio"); - if (e) { - e.play(); + getSoundForRoom: async function(roomId) { + // We do no caching here because the SDK caches setting + // and the browser will cache the sound. + const content = SettingsStore.getValue("notificationSound", roomId); + if (!content) { + return null; + } + + if (!content.url) { + console.warn(`${roomId} has custom notification sound event, but no url key`); + return null; + } + + if (!content.url.startsWith("mxc://")) { + console.warn(`${roomId} has custom notification sound event, but url is not a mxc url`); + return null; + } + + // Ideally in here we could use MSC1310 to detect the type of file, and reject it. + + return { + url: MatrixClientPeg.get().mxcUrlToHttp(content.url), + name: content.name, + type: content.type, + size: content.size, + }; + }, + + _playAudioNotification: async function(ev, room) { + const sound = await this.getSoundForRoom(room.roomId); + console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`); + + try { + const selector = document.querySelector(sound ? `audio[src='${sound.url}']` : "#messageAudio"); + let audioElement = selector; + if (!selector) { + if (!sound) { + console.error("No audio element or sound to play for notification"); + return; + } + audioElement = new Audio(sound.url); + if (sound.type) { + audioElement.type = sound.type; + } + document.body.appendChild(audioElement); + } + audioElement.play(); + } catch (ex) { + console.warn("Caught error when trying to fetch room notification sound:", ex); } }, diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js index c221289ff3..15b04ea504 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.js +++ b/src/components/views/dialogs/RoomSettingsDialog.js @@ -23,8 +23,10 @@ import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsT import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab"; import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab"; import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsTab"; +import NotificationSettingsTab from "../settings/tabs/room/NotificationSettingsTab"; import sdk from "../../../index"; import MatrixClientPeg from "../../../MatrixClientPeg"; +import SettingsStore from '../../../settings/SettingsStore'; import dis from "../../../dispatcher"; export default class RoomSettingsDialog extends React.Component { @@ -67,6 +69,11 @@ export default class RoomSettingsDialog extends React.Component { "mx_RoomSettingsDialog_rolesIcon", , )); + tabs.push(new Tab( + _td("Notifications"), + "mx_RoomSettingsDialog_rolesIcon", + , + )); tabs.push(new Tab( _td("Advanced"), "mx_RoomSettingsDialog_warningIcon", diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.js b/src/components/views/settings/tabs/room/NotificationSettingsTab.js new file mode 100644 index 0000000000..28c6e7dd5e --- /dev/null +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.js @@ -0,0 +1,178 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import {_t} from "../../../../../languageHandler"; +import MatrixClientPeg from "../../../../../MatrixClientPeg"; +import AccessibleButton from "../../../elements/AccessibleButton"; +import Notifier from "../../../../../Notifier"; +import SettingsStore from '../../../../../settings/SettingsStore'; +import { SettingLevel } from '../../../../../settings/SettingsStore'; + +export default class NotificationsSettingsTab extends React.Component { + static propTypes = { + roomId: PropTypes.string.isRequired, + }; + + constructor() { + super(); + + this.state = { + currentSound: "default", + uploadedFile: null, + }; + } + + componentWillMount() { + Notifier.getSoundForRoom(this.props.roomId).then((soundData) => { + if (!soundData) { + return; + } + this.setState({currentSound: soundData.name || soundData.url}); + }); + } + + async _triggerUploader(e) { + e.stopPropagation(); + e.preventDefault(); + + this.refs.soundUpload.click(); + } + + async _onSoundUploadChanged(e) { + if (!e.target.files || !e.target.files.length) { + this.setState({ + uploadedFile: null, + }); + return; + } + + const file = e.target.files[0]; + this.setState({ + uploadedFile: file, + }); + } + + async _onClickSaveSound(e) { + e.stopPropagation(); + e.preventDefault(); + + try { + await this._saveSound(); + } catch (ex) { + console.error( + `Unable to save notification sound for ${this.props.roomId}`, + ); + console.error(ex); + } + } + + async _saveSound() { + if (!this.state.uploadedFile) { + return; + } + + let type = this.state.uploadedFile.type; + if (type === "video/ogg") { + // XXX: I've observed browsers allowing users to pick a audio/ogg files, + // and then calling it a video/ogg. This is a lame hack, but man browsers + // suck at detecting mimetypes. + type = "audio/ogg"; + } + + const url = await MatrixClientPeg.get().uploadContent( + this.state.uploadedFile, { + type, + }, + ); + + await SettingsStore.setValue( + "notificationSound", + this.props.roomId, + SettingLevel.ROOM_ACCOUNT, + { + name: this.state.uploadedFile.name, + type: type, + size: this.state.uploadedFile.size, + url, + }, + ); + + this.setState({ + uploadedFile: null, + currentSound: this.state.uploadedFile.name, + }); + } + + _clearSound(e) { + e.stopPropagation(); + e.preventDefault(); + SettingsStore.setValue( + "notificationSound", + this.props.roomId, + SettingLevel.ROOM_ACCOUNT, + null, + ); + + this.setState({ + currentSound: "default", + }); + } + + render() { + let currentUploadedFile = null; + if (this.state.uploadedFile) { + currentUploadedFile = ( +
+ {_t("Uploaded sound")}: {this.state.uploadedFile.name} +
+ ); + } + + return ( +
+
{_t("Notifications")}
+
+ {_t("Sounds")} +
+ {_t("Notification sound")}: {this.state.currentSound}
+ + {_t("Reset")} + +
+
+

{_t("Set a new custom sound")}

+
+ +
+ + {currentUploadedFile} + + + {_t("Browse")} + + + + {_t("Save")} + +
+
+
+
+ ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cee450b3a5..7673b5f6ab 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -304,6 +304,7 @@ "Custom user status messages": "Custom user status messages", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Render simple counters in room header": "Render simple counters in room header", + "Custom Notification Sounds": "Custom Notification Sounds", "Edit messages after they have been sent (refresh to apply changes)": "Edit messages after they have been sent (refresh to apply changes)", "React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -624,6 +625,12 @@ "Room Addresses": "Room Addresses", "Publish this room to the public in %(domain)s's room directory?": "Publish this room to the public in %(domain)s's room directory?", "URL Previews": "URL Previews", + "Uploaded sound": "Uploaded sound", + "Sounds": "Sounds", + "Notification sound": "Notification sound", + "Reset": "Reset", + "Set a new custom sound": "Set a new custom sound", + "Browse": "Browse", "Change room avatar": "Change room avatar", "Change room name": "Change room name", "Change main address for the room": "Change main address for the room", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 7b5abc67a1..2ce1b1aa4e 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -27,6 +27,7 @@ import LowBandwidthController from "./controllers/LowBandwidthController"; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config']; +const LEVELS_ROOM_OR_ACCOUNT = ['room-account', 'account']; const LEVELS_ROOM_SETTINGS_WITH_ROOM = ['device', 'room-device', 'room-account', 'account', 'config', 'room']; const LEVELS_ACCOUNT_SETTINGS = ['device', 'account', 'config']; const LEVELS_FEATURE = ['device', 'config']; @@ -322,6 +323,10 @@ export const SETTINGS = { default: false, controller: new NotificationsEnabledController(), }, + "notificationSound": { + supportedLevels: LEVELS_ROOM_OR_ACCOUNT, + default: false, + }, "notificationBodyEnabled": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, default: true,