Skip to content
203 linesCodeBlameRaw
1//! Sound settings: what a person hears when chat moves, kept with their
2//! account so a phone, a laptop and the desktop app all sound the same.
3//!
4//! One row per person (migration 0046); a person with no row has the
5//! defaults. Only the person reads or changes their own. Which sound plays
6//! when is the web app's rule (apps/web/app/lib/chat-sounds.ts); this only
7//! keeps the choices, checking each against the sets and cues g1t has.
8//! Do not disturb is the person's presence (services/notify), not a row here.
9
10use std::collections::BTreeMap;
11
12use g1t_contracts::identity::*;
13use g1t_contracts::time::SQL_NOW;
14use g1t_contracts::{FailureCode, Outcome};
15use serde::Deserialize;
16use worker::Result;
17
18use crate::Identity;
19use crate::security::is_person;
20
21/// `current` with `change` over it, each field checked. Refused whole when
22/// a set or a cue is not one g1t has, or the volume is out of range.
23pub fn apply_change(current: &SoundSettings, change: &SoundSettingsChange) -> std::result::Result<SoundSettings, String> {
24 let mut next = current.clone();
25 if let Some(on) = change.sounds_enabled {
26 next.sounds_enabled = on;
27 }
28 if let Some(set) = &change.sound_set {
29 let set = set.trim();
30 if !SOUND_SETS.contains(&set) {
31 return Err("That is not a sound set g1t has.".to_owned());
32 }
33 next.sound_set = set.to_owned();
34 }
35 if let Some(volume) = change.sound_volume {
36 if !(0..=100).contains(&volume) {
37 return Err("Volume is from 0 to 100.".to_owned());
38 }
39 next.sound_volume = volume as u8;
40 }
41 if let Some(cues) = &change.sound_cues {
42 for (cue, on) in cues {
43 if !SOUND_CUES.contains(&cue.as_str()) {
44 return Err("That is not a sound g1t plays.".to_owned());
45 }
46 next.sound_cues.insert(cue.clone(), *on);
47 }
48 }
49 if let Some(on) = change.desktop_toasts {
50 next.desktop_toasts = on;
51 }
52 Ok(next)
53}
54
55#[derive(Deserialize)]
56struct Row {
57 sounds_enabled: i64,
58 sound_set: String,
59 sound_volume: i64,
60 sound_cues: String,
61 desktop_toasts: i64,
62}
63
64/// A row as settings: a field that cannot be read falls back to its
65/// default rather than failing the page, and a cue that no longer exists
66/// is dropped.
67fn settings_from(row: Row) -> SoundSettings {
68 let defaults = SoundSettings::default();
69 let mut cues = defaults.sound_cues.clone();
70 let saved: BTreeMap<String, bool> = serde_json::from_str(&row.sound_cues).unwrap_or_default();
71 for (cue, on) in saved {
72 if SOUND_CUES.contains(&cue.as_str()) {
73 cues.insert(cue, on);
74 }
75 }
76 SoundSettings {
77 sounds_enabled: row.sounds_enabled != 0,
78 sound_set: if SOUND_SETS.contains(&row.sound_set.as_str()) { row.sound_set } else { defaults.sound_set },
79 sound_volume: u8::try_from(row.sound_volume.clamp(0, 100)).unwrap_or(DEFAULT_SOUND_VOLUME),
80 sound_cues: cues,
81 desktop_toasts: row.desktop_toasts != 0,
82 }
83}
84
85const COLUMNS: &str = "sounds_enabled, sound_set, sound_volume, sound_cues, desktop_toasts";
86
87impl Identity {
88 async fn read_sound_settings(&self, user_id: &str) -> Result<SoundSettings> {
89 let row = self
90 .db
91 .prepare(format!("SELECT {COLUMNS} FROM sound_settings WHERE user_id = ?"))
92 .bind(&[user_id.into()])?
93 .first::<Row>(None)
94 .await?;
95 Ok(row.map(settings_from).unwrap_or_default())
96 }
97
98 pub async fn sound_settings(&self, a: SoundSettingsArgs) -> Result<SoundSettings> {
99 if !is_person(&a.user) {
100 return Ok(SoundSettings::default());
101 }
102 self.read_sound_settings(&a.user.id).await
103 }
104
105 pub async fn set_sound_settings(&self, a: SetSoundSettingsArgs) -> Result<Outcome<SoundSettings>> {
106 if !is_person(&a.user) {
107 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person has sound settings."));
108 }
109 let current = self.read_sound_settings(&a.user.id).await?;
110 let next = match apply_change(&current, &a.change) {
111 Ok(next) => next,
112 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
113 };
114 // Only the cues that differ from their defaults are kept, so a new
115 // default later applies to everyone who never chose.
116 let defaults = SoundSettings::default();
117 let chosen: BTreeMap<&String, &bool> = next.sound_cues.iter().filter(|(cue, on)| defaults.sound_cues.get(*cue) != Some(on)).collect();
118 let cues = serde_json::to_string(&chosen)?;
119 let row = self
120 .db
121 .prepare(format!(
122 "INSERT INTO sound_settings (user_id, sounds_enabled, sound_set, sound_volume, sound_cues, desktop_toasts, updated_at)
123 VALUES (?1, ?2, ?3, ?4, ?5, ?6, {SQL_NOW})
124 ON CONFLICT (user_id) DO UPDATE SET sounds_enabled = excluded.sounds_enabled, sound_set = excluded.sound_set,
125 sound_volume = excluded.sound_volume, sound_cues = excluded.sound_cues, desktop_toasts = excluded.desktop_toasts,
126 updated_at = excluded.updated_at
127 RETURNING {COLUMNS}"
128 ))
129 .bind(&[
130 a.user.id.as_str().into(),
131 i32::from(next.sounds_enabled).into(),
132 next.sound_set.as_str().into(),
133 i32::from(next.sound_volume).into(),
134 cues.as_str().into(),
135 i32::from(next.desktop_toasts).into(),
136 ])?
137 .first::<Row>(None)
138 .await?;
139 Ok(match row {
140 Some(row) => Outcome::Ok(settings_from(row)),
141 None => Outcome::Ok(next),
142 })
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn cues(list: &[(&str, bool)]) -> BTreeMap<String, bool> {
151 list.iter().map(|(cue, on)| ((*cue).to_owned(), *on)).collect()
152 }
153
154 #[test]
155 fn the_defaults_play_everything_but_the_tick_as_you_send() {
156 let d = SoundSettings::default();
157 assert!(d.sounds_enabled);
158 assert_eq!(d.sound_set, "soft");
159 assert_eq!(d.sound_volume, 60);
160 assert_eq!(d.sound_cues.get("message"), Some(&true));
161 assert_eq!(d.sound_cues.get("mention"), Some(&true));
162 assert_eq!(d.sound_cues.get("sent"), Some(&false));
163 assert!(!d.desktop_toasts);
164 }
165
166 #[test]
167 fn a_change_touches_only_what_it_names() {
168 let d = SoundSettings::default();
169 let next = apply_change(&d, &SoundSettingsChange { sound_volume: Some(30), sound_cues: Some(cues(&[("sent", true)])), ..Default::default() }).unwrap();
170 assert_eq!(next.sound_volume, 30);
171 assert_eq!(next.sound_set, "soft");
172 assert_eq!(next.sound_cues.get("sent"), Some(&true));
173 assert_eq!(next.sound_cues.get("message"), Some(&true));
174 let bright = apply_change(&next, &SoundSettingsChange { sound_set: Some("bright".to_owned()), ..Default::default() }).unwrap();
175 assert_eq!(bright.sound_set, "bright");
176 assert_eq!(bright.sound_volume, 30);
177 }
178
179 #[test]
180 fn an_unknown_set_cue_or_volume_is_refused() {
181 let d = SoundSettings::default();
182 assert!(apply_change(&d, &SoundSettingsChange { sound_set: Some("loud".to_owned()), ..Default::default() }).is_err());
183 assert!(apply_change(&d, &SoundSettingsChange { sound_volume: Some(101), ..Default::default() }).is_err());
184 assert!(apply_change(&d, &SoundSettingsChange { sound_volume: Some(-1), ..Default::default() }).is_err());
185 assert!(apply_change(&d, &SoundSettingsChange { sound_cues: Some(cues(&[("call", false)])), ..Default::default() }).is_err());
186 assert!(apply_change(&d, &SoundSettingsChange { sound_cues: Some(cues(&[("klaxon", true)])), ..Default::default() }).is_err());
187 }
188
189 #[test]
190 fn a_row_reads_with_defaults_for_what_it_cannot_say() {
191 let row = Row { sounds_enabled: 0, sound_set: "gone".to_owned(), sound_volume: 400, sound_cues: "not json".to_owned(), desktop_toasts: 1 };
192 let s = settings_from(row);
193 assert!(!s.sounds_enabled);
194 assert_eq!(s.sound_set, "soft");
195 assert_eq!(s.sound_volume, 100);
196 assert_eq!(s.sound_cues, SoundSettings::default().sound_cues);
197 assert!(s.desktop_toasts);
198 let row = Row { sounds_enabled: 1, sound_set: "bright".to_owned(), sound_volume: 20, sound_cues: r#"{"sent":true,"old_cue":true}"#.to_owned(), desktop_toasts: 0 };
199 let s = settings_from(row);
200 assert_eq!(s.sound_cues.get("sent"), Some(&true));
201 assert_eq!(s.sound_cues.get("old_cue"), None);
202 }
203}