Add a display name and a default device
The display name replaces the username on the map. The default device is the one the map selects first, also for viewers of a share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mcrates/api/src/lib.rs
@@ -32,6 +32,11 @@ pub struct Point {
pub struct Person {
pub id: i64,
pub username: String,
#[serde(default)]
pub display_name: Option<String>,
/// The device the map selects first. One of `devices`.
#[serde(default)]
pub default_device: Option<i64>,
/// The visible devices that have a position, newest first.
pub devices: Vec<PersonDevice>,
/// As in ShareSettings: None shows only the current position.
@@ -45,6 +50,10 @@ impl Person {
pub fn last(&self) -> Option<&PersonDevice> {
self.devices.first()
}
pub fn name(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.username)
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
@@ -58,6 +67,8 @@ pub struct PersonDevice {
pub struct Me {
pub id: i64,
pub username: String,
pub display_name: Option<String>,
pub default_device: Option<i64>,
pub is_admin: bool,
pub has_password: bool,
/// Sign-in needs the password and a passkey. Otherwise either one is enough.
@@ -143,6 +154,18 @@ pub struct SetTwoFactor {
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SetDisplayName {
/// None shows the username.
pub name: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SetDefaultDevice {
/// None selects the device with the newest position.
pub device: Option<i64>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SetRetention {
/// None means the server limit.
Mcrates/server/src/device.rs
@@ -35,6 +35,7 @@ pub async fn me(State(s): State<AppState>, d: Device) -> Result<Json<Person>> {
let db = s.db();
let mut person = person_for(&db, own_access(&db, d.user_id)?)?;
person.devices.retain(|x| x.id == d.id);
person.default_device = person.default_device.filter(|id| *id == d.id);
Ok(Json(person))
}
Mcrates/server/src/main.rs
@@ -189,6 +189,11 @@ UPDATE shares SET precision_m = CASE
WHEN precision_m <= 1000 THEN 1000
WHEN precision_m <= 10000 THEN 10000
ELSE 100000 END;
",
// The name others see, and the device the map selects first.
"
ALTER TABLE users ADD COLUMN display_name TEXT;
ALTER TABLE users ADD COLUMN default_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL;
",
];
Mcrates/server/src/routes.rs
@@ -3,7 +3,8 @@ use std::path::Path;
use api::{
ChangePassword, Credentials, Device, DeviceToken, Login, MAX_BATCH, MAX_TRACK_SECS, Me,
NewDevice, NewShare, NewUser, PRECISIONS_M, Person, PersonDevice, Point, ResetPassword,
SetRetention, SetRole, SetTwoFactor, SetupStatus, Share, ShareSettings, Shares, Uploaded,
SetDefaultDevice, SetDisplayName, SetRetention, SetRole, SetTwoFactor, SetupStatus, Share,
ShareSettings, Shares, Uploaded,
};
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, HeaderValue, Uri, header};
@@ -38,6 +39,8 @@ pub fn router(state: AppState, web_dir: &Path) -> Router {
)
.route("/api/me/two-factor", put(set_two_factor))
.route("/api/me/retention", put(set_retention))
.route("/api/me/display-name", put(set_display_name))
.route("/api/me/default-device", put(set_default_device))
.route("/api/passkeys", get(passkeys::list))
.route("/api/passkeys/register", post(passkeys::register_begin))
.route(
@@ -183,14 +186,16 @@ async fn logout(State(s): State<AppState>, user: User) -> Result<impl IntoRespon
}
async fn me(State(s): State<AppState>, user: User) -> Result<Json<Me>> {
let (has_password, two_factor, retention_days) = s.db().query_row(
"SELECT pw_hash IS NOT NULL, two_factor, retention_days FROM users WHERE id = ?1",
let (has_password, two_factor, retention_days, display_name, default_device) = s.db().query_row(
"SELECT pw_hash IS NOT NULL, two_factor, retention_days, display_name, default_device_id FROM users WHERE id = ?1",
[user.id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)?;
Ok(Json(Me {
id: user.id,
username: user.username,
display_name,
default_device,
is_admin: user.is_admin,
has_password,
two_factor,
@@ -320,6 +325,47 @@ async fn set_retention(
Ok(Json(()))
}
async fn set_display_name(
State(s): State<AppState>,
user: User,
Json(b): Json<SetDisplayName>,
) -> Result<Json<()>> {
let name = b.name.as_deref().map(str::trim).filter(|n| !n.is_empty());
if name.is_some_and(|n| n.chars().count() > 64) {
return Err(Error::BadRequest(
"the display name must have at most 64 characters".into(),
));
}
s.db().execute(
"UPDATE users SET display_name = ?1 WHERE id = ?2",
params![name, user.id],
)?;
Ok(Json(()))
}
async fn set_default_device(
State(s): State<AppState>,
user: User,
Json(b): Json<SetDefaultDevice>,
) -> Result<Json<()>> {
let db = s.db();
if let Some(id) = b.device {
let mine: bool = db.query_row(
"SELECT EXISTS (SELECT 1 FROM devices WHERE id = ?1 AND user_id = ?2)",
[id, user.id],
|r| r.get(0),
)?;
if !mine {
return Err(Error::NotFound);
}
}
db.execute(
"UPDATE users SET default_device_id = ?1 WHERE id = ?2",
params![b.device, user.id],
)?;
Ok(Json(()))
}
const POINT_COLS: &str = "ts, lat, lon, acc, alt, speed, bearing, battery";
/// POINT_COLS, with the stored cell of `m` metres in place of the exact position.
@@ -456,9 +502,18 @@ pub fn person_for(db: &Connection, a: Access) -> rusqlite::Result<Person> {
.collect::<rusqlite::Result<_>>()?;
// By rounded time, so the order does not tell which device sent last within the same rounded time.
devices.sort_by_key(|d| (std::cmp::Reverse(d.last.ts), d.id));
let (display_name, default_device): (Option<String>, Option<i64>) = db.query_row(
"SELECT display_name, default_device_id FROM users WHERE id = ?1",
[a.owner],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
// A hidden device's id must not reach the viewer.
let default_device = default_device.filter(|id| devices.iter().any(|d| d.id == *id));
Ok(Person {
id: a.owner,
username: a.username,
display_name,
default_device,
devices,
trail_since: a.trail_since,
precision_m: a.precision_m,
@@ -1125,12 +1180,26 @@ mod tests {
.map(|d| d.name.clone())
.collect()
};
let default = |db: &Connection| people_for(db, 1).unwrap()[1].default_device;
db.execute("UPDATE users SET default_device_id = 11 WHERE id = 2", [])
.unwrap();
assert_eq!(devices(&db), ["car", "phone"]);
assert_eq!(default(&db), Some(11));
db.execute_batch(
"UPDATE shares SET all_devices = 0; INSERT INTO share_devices VALUES (5, 10);",
)
.unwrap();
assert_eq!(devices(&db), ["phone"]);
assert_eq!(default(&db), None);
db.execute("DELETE FROM devices WHERE id = 11", []).unwrap();
let cleared: Option<i64> = db
.query_row(
"SELECT default_device_id FROM users WHERE id = 2",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cleared, None);
}
#[test]
Mweb/src/guest.rs
@@ -33,7 +33,7 @@ pub fn GuestPage(token: String) -> impl IntoView {
let check = move || {
spawn_local(async move {
state.set(match http::post::<GuestView>("/api/guest", &auth()).await {
Ok(v) => State::Open(v.person.username),
Ok(v) => State::Open(v.person.name().to_owned()),
Err(e) if e.status == 401 => State::Locked,
Err(e) if e.status == 404 => State::Gone,
Err(e) => State::Failed(e.to_string()),
Mweb/src/i18n.rs
@@ -60,6 +60,21 @@ fn fill(de: &str, values: &[&str]) -> String {
/// English key, German text.
const DE: &[(&str, &str)] = &[
("Clear default", "Standard entfernen"),
(
"Default: the map shows this device first",
"Standard: die Karte zeigt dieses Gerät zuerst",
),
("Display name", "Anzeigename"),
("Make default", "Als Standard"),
(
"Others see this name on the map. Leave it empty to show your username.",
"Andere sehen diesen Namen auf der Karte. Lass das Feld leer, um deinen Benutzernamen zu zeigen.",
),
(
"The map selects the default device first, also for people you share with. Without a default, it selects the device with the newest position.",
"Die Karte wählt zuerst das Standardgerät, auch für Personen, mit denen du teilst. Ohne Standard wählt sie das Gerät mit der neuesten Position.",
),
("Theme: light", "Design: hell"),
("Theme: dark", "Design: dunkel"),
("Theme: auto", "Design: automatisch"),
@@ -405,6 +420,10 @@ const DE: &[(&str, &str)] = &[
("seconds", "Sekunden"),
("since a time", "seit einem Zeitpunkt"),
("some devices", "einige Geräte"),
(
"the display name must have at most 64 characters",
"der Anzeigename darf höchstens 64 Zeichen haben",
),
("unnamed", "ohne Namen"),
("until I stop it", "bis ich es beende"),
("user", "Benutzer"),
Mweb/src/main.rs
@@ -281,7 +281,7 @@ fn Shell() -> impl IntoView {
</button>
</nav>
<ThemeToggle />
<span class="user">{move || account.me().map(|m| m.username)}</span>
<span class="user">{move || account.me().map(|m| m.display_name.unwrap_or(m.username))}</span>
<button on:click=logout>{tr("Log out")}</button>
</header>
<main>
Mweb/src/map.rs
@@ -246,7 +246,7 @@ fn sync_markers(
);
// Leaflet inserts tooltip text as HTML.
dot.bind_tooltip(
&escape(&p.username),
&escape(p.name()),
&obj(&[
("permanent", true.into()),
("direction", "right".into()),
@@ -348,6 +348,7 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
let chosen = trail_device.get();
chosen
.filter(|id| p.devices.iter().any(|d| d.id == *id))
.or(p.default_device)
.or(p.last().map(|d| d.id))
};
let (today, now) = input_values(&js_sys::Date::new_0());
@@ -563,7 +564,7 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
return;
}
selected.set(Some(id));
trail_device.set(p.last().map(|d| d.id));
trail_device.set(p.default_device.or(p.last().map(|d| d.id)));
if let Some(last) = p.last().map(|d| &d.last) {
map.with_value(|m| m.as_ref().map(|m| m.pan_to(&latlng(last.lat, last.lon))));
}
@@ -582,7 +583,7 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
.map(|(i, p)| {
let id = p.id;
let detail = describe(&p);
let name = if i == 0 && !is_guest { tr(&format!("{} (you)", p.username)) } else { p.username.clone() };
let name = if i == 0 && !is_guest { tr(&format!("{} (you)", p.name())) } else { p.name().to_owned() };
view! {
<li class:selected=move || selected.get() == Some(id) on:click=move |_| select(&p)>
<span class="dot" style:background=color(id)></span>
@@ -632,10 +633,10 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
{move || match (person(), range()) {
(_, Err(msg)) => tr(msg),
(None, _) => tr("Select a person to show their trail."),
(Some(p), _) if p.trail_since.is_none() => tr(&format!("{} shares only their current position.", p.username)),
(Some(p), _) if p.trail_since.is_none() => tr(&format!("{} shares only their current position.", p.name())),
_ if span.get() == "none" => String::new(),
(Some(Person { trail_since: Some(t @ 1..), username, .. }), _) => {
format!("{} {}", count(track.get().len()), tr(&format!("{username} shares the trail since {}.", fmt_time(t))))
(Some(p @ Person { trail_since: Some(t @ 1..), .. }), _) => {
format!("{} {}", count(track.get().len()), tr(&format!("{} shares the trail since {}.", p.name(), fmt_time(t))))
}
(Some(_), _) => count(track.get().len()),
}}
Mweb/src/settings.rs
@@ -1,7 +1,7 @@
use api::{
Challenge, ChallengeAnswer, ChangePassword, Device, DeviceToken, Link, NewDevice, NewLink,
NewShare, NewUser, Passkey, ResetPassword, SetRetention, SetRole, SetTwoFactor, Share,
ShareSettings, Shares, User,
NewShare, NewUser, Passkey, ResetPassword, SetDefaultDevice, SetDisplayName, SetRetention,
SetRole, SetTwoFactor, Share, ShareSettings, Shares, User,
};
use leptos::prelude::*;
use leptos::task::spawn_local;
@@ -40,7 +40,7 @@ pub fn Settings() -> impl IntoView {
<Show when=is_admin>{tab_button(Tab::Users, "Users")}</Show>
</nav>
{move || match tab.get() {
Tab::Sharing => view! { <SharesSection /> }.into_any(),
Tab::Sharing => view! { <DisplayName /> <SharesSection /> }.into_any(),
Tab::Links => view! { <Links /> }.into_any(),
Tab::Devices => view! { <Devices /> }.into_any(),
Tab::History => view! { <History /> }.into_any(),
@@ -159,6 +159,18 @@ fn Devices() -> impl IntoView {
});
};
let set_default = move |device: Option<i64>| {
spawn_local(async move {
match http::put::<()>("/api/me/default-device", &SetDefaultDevice { device }).await {
Ok(()) => {
error.set(None);
account.reload();
}
Err(e) => error.set(Some(e.to_string())),
}
});
};
let token_url = url.clone();
view! {
<section>
@@ -183,17 +195,30 @@ fn Devices() -> impl IntoView {
</section>
<section>
<h2>{tr("Devices")}</h2>
{table(devices, "No devices yet.", ["Name", "Added", "Last upload"], move |d| view! {
<tr>
<td>
{d.name.clone()}
{d.web.then(|| view! { <div class="hint">{tr("The web app, in any browser")}</div> })}
</td>
<td>{fmt_time(d.created_at)}</td>
<td>{d.last_seen_at.map_or(tr("never"), ago)}</td>
<td class="actions"><button on:click=move |_| remove(&d)>{tr("Remove")}</button></td>
</tr>
{table(devices, "No devices yet.", ["Name", "Added", "Last upload"], move |d| {
let id = d.id;
let is_default = move || account.me().and_then(|m| m.default_device) == Some(id);
view! {
<tr>
<td>
{d.name.clone()}
{d.web.then(|| view! { <div class="hint">{tr("The web app, in any browser")}</div> })}
<Show when=is_default>
<div class="hint">{tr("Default: the map shows this device first")}</div>
</Show>
</td>
<td>{fmt_time(d.created_at)}</td>
<td>{d.last_seen_at.map_or(tr("never"), ago)}</td>
<td class="actions">
<button on:click=move |_| set_default((!is_default()).then_some(id))>
{move || tr(if is_default() { "Clear default" } else { "Make default" })}
</button>
<button on:click=move |_| remove(&d)>{tr("Remove")}</button>
</td>
</tr>
}
})}
<p class="hint">{tr("The map selects the default device first, also for people you share with. Without a default, it selects the device with the newest position.")}</p>
</section>
}
}
@@ -619,6 +644,42 @@ fn Links() -> impl IntoView {
}
}
#[component]
fn DisplayName() -> impl IntoView {
let account = expect_context::<Account>();
let me = account.me().unwrap();
let name = RwSignal::new(me.display_name.unwrap_or_default());
let message = RwSignal::new(None::<Result<String, String>>);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let body = SetDisplayName {
name: Some(name.get_untracked()),
};
spawn_local(async move {
match http::put::<()>("/api/me/display-name", &body).await {
Ok(()) => {
message.set(Some(Ok(tr("Saved."))));
account.reload();
}
Err(e) => message.set(Some(Err(e.to_string()))),
}
});
};
view! {
<section>
<h2>{tr("Display name")}</h2>
<form on:submit=submit>
<label>{tr("Name")} <input maxlength="64" placeholder=me.username bind:value=name /></label>
<button class="primary">{tr("Save")}</button>
</form>
<p class="hint">{tr("Others see this name on the map. Leave it empty to show your username.")}</p>
{status(message)}
</section>
}
}
#[component]
fn History() -> impl IntoView {
let account = expect_context::<Account>();