Skip to content
203 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.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}