Update Pexels and fix binding loops
This commit is contained in:
parent
4c7bd2694f
commit
48c493c4f5
@ -5,7 +5,6 @@ build = "build.rs"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
#slint = { version = "1.16.1", default-features = false, features = [
|
|
||||||
slint = { git = "https://github.com/slint-ui/slint", branch = "master", default-features = false, features = [
|
slint = { git = "https://github.com/slint-ui/slint", branch = "master", default-features = false, features = [
|
||||||
"std",
|
"std",
|
||||||
"compat-1-2",
|
"compat-1-2",
|
||||||
@ -20,7 +19,7 @@ greetd_ipc = { version = "0.10.3", features = [
|
|||||||
"tokio-codec",
|
"tokio-codec",
|
||||||
] }
|
] }
|
||||||
i-slint-core = { git = "https://github.com/slint-ui/slint", branch = "master" }
|
i-slint-core = { git = "https://github.com/slint-ui/slint", branch = "master" }
|
||||||
toml = { version = "1.1.2+spec-1.1.0", features = ["serde"] }
|
toml = { version = "1.1.2", features = ["serde"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde-aux = "4.7.0"
|
serde-aux = "4.7.0"
|
||||||
zbus = { version = "5.15.0", features = ["blocking"] }
|
zbus = { version = "5.15.0", features = ["blocking"] }
|
||||||
@ -29,11 +28,11 @@ tokio-stream = {version = "0.1.18", features = ["fs"]}
|
|||||||
greetd-stub = "0.3.0"
|
greetd-stub = "0.3.0"
|
||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
|
|
||||||
pexels-api = "0.0.5"
|
pexels-api = { git = "https://github.com/houseme/pexels", rev = "b0b692a" }
|
||||||
reqwest = { version = "0.13.3", features = ["json", "default-tls"] }
|
reqwest = { version = "0.13.3", features = ["json", "default-tls"] }
|
||||||
|
|
||||||
magick_rust = "2.0.0"
|
magick_rust = "2.0.0"
|
||||||
|
rand = "0.9.4"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
#slint-build = "1.16.1"
|
|
||||||
slint-build = { git = "https://github.com/slint-ui/slint", branch = "master" }
|
slint-build = { git = "https://github.com/slint-ui/slint", branch = "master" }
|
||||||
|
|||||||
@ -8,7 +8,8 @@ use greetd_ipc::codec::TokioCodec;
|
|||||||
use i_slint_core::api::{ComponentHandle, Global, Image, Rgba8Pixel, SharedPixelBuffer, Weak};
|
use i_slint_core::api::{ComponentHandle, Global, Image, Rgba8Pixel, SharedPixelBuffer, Weak};
|
||||||
use i_slint_core::model::{Model, VecModel};
|
use i_slint_core::model::{Model, VecModel};
|
||||||
use magick_rust::MagickWand;
|
use magick_rust::MagickWand;
|
||||||
use pexels_api::{Pexels, SearchBuilder};
|
use pexels_api::{PexelsClient, SearchParams};
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader, Lines};
|
use tokio::io::{AsyncBufReadExt, BufReader, Lines};
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
@ -273,7 +274,6 @@ pub fn register_wallpaper_callback(data: &GreeterData, display_weak: Weak<Greete
|
|||||||
|
|
||||||
let reqwest_client = match reqwest::ClientBuilder::new()
|
let reqwest_client = match reqwest::ClientBuilder::new()
|
||||||
.tls_backend_rustls()
|
.tls_backend_rustls()
|
||||||
// .timeout(std::time::Duration::from_secs(60))
|
|
||||||
.http2_prior_knowledge()
|
.http2_prior_knowledge()
|
||||||
.http2_keep_alive_timeout(std::time::Duration::from_secs(10))
|
.http2_keep_alive_timeout(std::time::Duration::from_secs(10))
|
||||||
.build() {
|
.build() {
|
||||||
@ -285,7 +285,7 @@ pub fn register_wallpaper_callback(data: &GreeterData, display_weak: Weak<Greete
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Some(pexels_api_key) = config.pexels_api_key.clone() {
|
if let Some(pexels_api_key) = config.pexels_api_key.clone() {
|
||||||
let pexels_client = Arc::new(Pexels::new(pexels_api_key));
|
let pexels_client = Arc::new(PexelsClient::new(pexels_api_key));
|
||||||
let pexels_query = config
|
let pexels_query = config
|
||||||
.pexels_query
|
.pexels_query
|
||||||
.clone()
|
.clone()
|
||||||
@ -326,18 +326,15 @@ pub fn register_wallpaper_callback(data: &GreeterData, display_weak: Weak<Greete
|
|||||||
}
|
}
|
||||||
drop(file_lines);
|
drop(file_lines);
|
||||||
|
|
||||||
match tokio::time::timeout(
|
match pexels_client
|
||||||
std::time::Duration::from_secs(30),
|
.search_photos(
|
||||||
pexels_client
|
pexels_query.as_str(),
|
||||||
.search_photos(
|
&SearchParams::new()
|
||||||
SearchBuilder::new()
|
.page(1)
|
||||||
.query(pexels_query.as_str())
|
.per_page(11)
|
||||||
.page(1)
|
.size(pexels_api::Size::Medium)
|
||||||
.per_page(11)
|
.orientation(pexels_api::Orientation::Landscape)
|
||||||
.size(pexels_api::Size::Medium)
|
).await
|
||||||
.orientation(pexels_api::Orientation::Landscape),
|
|
||||||
)
|
|
||||||
).await?
|
|
||||||
{
|
{
|
||||||
Ok(photos) => {
|
Ok(photos) => {
|
||||||
if photos.total_results > 0 {
|
if photos.total_results > 0 {
|
||||||
@ -399,7 +396,7 @@ pub fn register_wallpaper_callback(data: &GreeterData, display_weak: Weak<Greete
|
|||||||
drop(pixels);
|
drop(pixels);
|
||||||
|
|
||||||
data.set_wallpaper_author(photo.photographer.into());
|
data.set_wallpaper_author(photo.photographer.into());
|
||||||
data.set_wallpaper_alt(photo.alt.into());
|
data.set_wallpaper_alt(photo.alt.unwrap_or("".into()).into());
|
||||||
data.set_wallpaper_url(photo.url.into());
|
data.set_wallpaper_url(photo.url.into());
|
||||||
|
|
||||||
display.invoke_switch_background();
|
display.invoke_switch_background();
|
||||||
@ -424,7 +421,7 @@ pub fn register_wallpaper_callback(data: &GreeterData, display_weak: Weak<Greete
|
|||||||
drop(pixels);
|
drop(pixels);
|
||||||
|
|
||||||
data.set_wallpaper_author(photo.photographer.into());
|
data.set_wallpaper_author(photo.photographer.into());
|
||||||
data.set_wallpaper_alt(photo.alt.into());
|
data.set_wallpaper_alt(photo.alt.unwrap_or("".into()).into());
|
||||||
data.set_wallpaper_url(photo.url.into());
|
data.set_wallpaper_url(photo.url.into());
|
||||||
|
|
||||||
display.invoke_switch_background();
|
display.invoke_switch_background();
|
||||||
@ -449,7 +446,11 @@ pub fn register_wallpaper_callback(data: &GreeterData, display_weak: Weak<Greete
|
|||||||
== Some(std::ffi::OsStr::new("png"))
|
== Some(std::ffi::OsStr::new("png"))
|
||||||
}
|
}
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
});
|
}).collect::<Vec<_>>().await;
|
||||||
|
|
||||||
|
photos.shuffle(&mut rand::rng());
|
||||||
|
|
||||||
|
let mut photos = tokio_stream::iter(photos);
|
||||||
|
|
||||||
while let Some(photo) = photos.next().await {
|
while let Some(photo) = photos.next().await {
|
||||||
match photo {
|
match photo {
|
||||||
@ -552,6 +553,13 @@ pub fn register_clean_inactive_wallpaper_callback(data: &GreeterData, display_we
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pub fn register_get_default_font_size(data: &GreeterData, display_weak: Weak<GreeterDisplay>) {
|
||||||
|
data.on_get_default_font_size(move || {
|
||||||
|
let display = display_weak.unwrap();
|
||||||
|
let size = display.window().size();
|
||||||
|
((size.width as f32).min(size.height as f32)) * 0.01
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn register_callbacks(
|
pub fn register_callbacks(
|
||||||
display: &GreeterDisplay,
|
display: &GreeterDisplay,
|
||||||
@ -568,4 +576,5 @@ pub fn register_callbacks(
|
|||||||
register_lock_state_callback(data, data_weak.clone());
|
register_lock_state_callback(data, data_weak.clone());
|
||||||
register_wallpaper_callback(data, display_weak.clone(), data_weak.clone(), cache_dir, config);
|
register_wallpaper_callback(data, display_weak.clone(), data_weak.clone(), cache_dir, config);
|
||||||
register_clean_inactive_wallpaper_callback(data, display_weak.clone(), data_weak.clone());
|
register_clean_inactive_wallpaper_callback(data, display_weak.clone(), data_weak.clone());
|
||||||
|
register_get_default_font_size(data, display_weak.clone());
|
||||||
}
|
}
|
||||||
@ -5,6 +5,8 @@ export global Data {
|
|||||||
|
|
||||||
in-out property <int> error_count;
|
in-out property <int> error_count;
|
||||||
|
|
||||||
|
pure callback get-default-font-size() -> length;
|
||||||
|
|
||||||
callback login(username: string, password: string);
|
callback login(username: string, password: string);
|
||||||
callback loginctl(command: string);
|
callback loginctl(command: string);
|
||||||
callback check-lock-states();
|
callback check-lock-states();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ScrollView, Button } from "std-widgets.slint";
|
import { ScrollView, Button, TabWidget } from "std-widgets.slint";
|
||||||
import { Data, LoginOptionTileData } from "data.slint";
|
import { Data, LoginOptionTileData } from "data.slint";
|
||||||
import { LoginOptionTile } from "envtile.slint";
|
import { LoginOptionTile } from "envtile.slint";
|
||||||
|
|
||||||
@ -9,7 +9,19 @@ import { IconButton } from "iconbutton.slint";
|
|||||||
export { Data as GreeterData } from "data.slint";
|
export { Data as GreeterData } from "data.slint";
|
||||||
|
|
||||||
export component GreeterDisplay inherits Window {
|
export component GreeterDisplay inherits Window {
|
||||||
default-font-size: root.width < root.height ? root.width * 0.01 : root.height * 0.01;
|
changed width => {
|
||||||
|
self.default-font-size = Data.get-default-font-size();
|
||||||
|
login_manager_underlay.width = max(self.width * 0.33, 640px);
|
||||||
|
login_manager_underlay.height = self.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed height => {
|
||||||
|
self.default-font-size = Data.get-default-font-size();
|
||||||
|
login_manager_underlay.width = max(self.width * 0.33, 640px);
|
||||||
|
login_manager_underlay.height = self.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
default-font-size: Data.get-default-font-size();
|
||||||
|
|
||||||
in-out property <int> selected_index <=> Data.selected_index;
|
in-out property <int> selected_index <=> Data.selected_index;
|
||||||
|
|
||||||
@ -39,6 +51,8 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
full-screen: true;
|
full-screen: true;
|
||||||
|
preferred-width: 1920px;
|
||||||
|
preferred-height: 1080px;
|
||||||
min-width: 640px;
|
min-width: 640px;
|
||||||
min-height: 480px;
|
min-height: 480px;
|
||||||
|
|
||||||
@ -62,9 +76,6 @@ export component GreeterDisplay inherits Window {
|
|||||||
source: Data.wallpaper-image;
|
source: Data.wallpaper-image;
|
||||||
image-fit: ImageFit.cover;
|
image-fit: ImageFit.cover;
|
||||||
|
|
||||||
width: parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
|
|
||||||
opacity: background-toggle && self.source.width > 0 ? 1.0 : 0.0;
|
opacity: background-toggle && self.source.width > 0 ? 1.0 : 0.0;
|
||||||
background_timer := Timer {
|
background_timer := Timer {
|
||||||
interval: Data.wallpaper-cooldown * 1s;
|
interval: Data.wallpaper-cooldown * 1s;
|
||||||
@ -95,9 +106,6 @@ export component GreeterDisplay inherits Window {
|
|||||||
source: Data.wallpaper-image-1;
|
source: Data.wallpaper-image-1;
|
||||||
image-fit: ImageFit.cover;
|
image-fit: ImageFit.cover;
|
||||||
|
|
||||||
width: parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
|
|
||||||
opacity: !background-toggle && self.source.width > 0 ? 1.0 : 0.0;
|
opacity: !background-toggle && self.source.width > 0 ? 1.0 : 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +120,7 @@ export component GreeterDisplay inherits Window {
|
|||||||
x: 1rem;
|
x: 1rem;
|
||||||
y: root.height - 7.5rem;
|
y: root.height - 7.5rem;
|
||||||
height: 6.5rem;
|
height: 6.5rem;
|
||||||
width: parent.width * 0.33;
|
width: 33%;
|
||||||
|
|
||||||
private property <bool> show_extended;
|
private property <bool> show_extended;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
@ -174,8 +182,6 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TouchArea {
|
TouchArea {
|
||||||
height: parent.height;
|
|
||||||
width: parent.width;
|
|
||||||
clicked => {
|
clicked => {
|
||||||
show_extended = !show_extended
|
show_extended = !show_extended
|
||||||
}
|
}
|
||||||
@ -185,19 +191,21 @@ export component GreeterDisplay inherits Window {
|
|||||||
login_manager_underlay := Rectangle {
|
login_manager_underlay := Rectangle {
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
drop-shadow-blur: 4rem;
|
drop-shadow-blur: 4rem;
|
||||||
height: parent.height;
|
height: 0px;
|
||||||
|
width: 0px;
|
||||||
|
|
||||||
|
animate width {
|
||||||
|
duration: 500ms;
|
||||||
|
easing: ease-in-sine;
|
||||||
|
}
|
||||||
|
|
||||||
z: -1;
|
z: -1;
|
||||||
width: max(parent.width * 0.33, 640px);
|
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
height: parent.height;
|
|
||||||
width: parent.width;
|
|
||||||
|
|
||||||
clock := VerticalLayout {
|
clock := VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
height: parent.height * 0.15;
|
height: 15%;
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
width: parent.width;
|
|
||||||
alignment: center;
|
alignment: center;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
y: 10rem;
|
y: 10rem;
|
||||||
@ -243,8 +251,7 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
login_model := VerticalLayout {
|
login_model := VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
height: parent.height * 0.70;
|
height: 70%;
|
||||||
width: parent.width;
|
|
||||||
|
|
||||||
spacing: 1rem;
|
spacing: 1rem;
|
||||||
|
|
||||||
@ -262,8 +269,7 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
environment_selector := FocusScope {
|
environment_selector := FocusScope {
|
||||||
height: max(parent.width * 0.2, parent.height * 0.2);
|
height: 25%;
|
||||||
width: parent.width;
|
|
||||||
key-pressed(event) => {
|
key-pressed(event) => {
|
||||||
if (event.text == Key.LeftArrow || event.text == "a" || event.text == "A") {
|
if (event.text == Key.LeftArrow || event.text == "a" || event.text == "A") {
|
||||||
if (Data.selected_index == 0) {
|
if (Data.selected_index == 0) {
|
||||||
@ -284,22 +290,17 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scroll_view := ScrollView {
|
scroll_view := ScrollView {
|
||||||
height: parent.height;
|
|
||||||
width: min(((self.height + 1rem) * tiles.length) - 1rem, parent.width);
|
width: min(((self.height + 1rem) * tiles.length) - 1rem, parent.width);
|
||||||
|
|
||||||
animate viewport-x { duration: 250ms; }
|
animate viewport-x { duration: 250ms; }
|
||||||
|
|
||||||
viewport-width: ((self.height + 1rem) * tiles.length) - 1rem;
|
viewport-width: ((self.height + 1rem) * tiles.length) - 1rem;
|
||||||
// viewport-x: -(Data.selected_index * self.height) + (self.height / 2);
|
|
||||||
viewport-x: (self.viewport-width - self.height) / 2 - (Data.selected_index * (self.height));
|
viewport-x: (self.viewport-width - self.height) / 2 - (Data.selected_index * (self.height));
|
||||||
|
|
||||||
//x: self.viewport-width < self.width ? ((self.width - self.viewport-width) / 2) : 0;
|
|
||||||
|
|
||||||
scroll_layout := HorizontalLayout {
|
scroll_layout := HorizontalLayout {
|
||||||
height: parent.height;
|
height: parent.height;
|
||||||
for tile[i] in tiles: LoginOptionTile {
|
for tile[i] in tiles: LoginOptionTile {
|
||||||
width: parent.height;
|
width: parent.height;
|
||||||
height: parent.height;
|
|
||||||
icon: tile.image;
|
icon: tile.image;
|
||||||
label: tile.name;
|
label: tile.name;
|
||||||
index: i;
|
index: i;
|
||||||
@ -313,13 +314,12 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
// Username field
|
// Username field
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
width: parent.width;
|
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
alignment: center;
|
alignment: center;
|
||||||
username_box := LineEdit {
|
username_box := LineEdit {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-family: "monospace";
|
font-family: "monospace";
|
||||||
width: parent.width * 0.45;
|
width: 45%;
|
||||||
placeholder-text: "Username";
|
placeholder-text: "Username";
|
||||||
text: default_username;
|
text: default_username;
|
||||||
border-color: rgba(185, 15, 220, 0.85);
|
border-color: rgba(185, 15, 220, 0.85);
|
||||||
@ -338,13 +338,12 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
// Password field
|
// Password field
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
width: parent.width;
|
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
alignment: center;
|
alignment: center;
|
||||||
password_box := LineEdit {
|
password_box := LineEdit {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-family: "monospace";
|
font-family: "monospace";
|
||||||
width: parent.width * 0.45;
|
width: username_box.width;
|
||||||
placeholder-text: "Password";
|
placeholder-text: "Password";
|
||||||
input-type: password;
|
input-type: password;
|
||||||
border-color: rgba(185, 15, 220, 0.85);
|
border-color: rgba(185, 15, 220, 0.85);
|
||||||
@ -362,11 +361,10 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
// Submit button
|
// Submit button
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
width: parent.width;
|
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
alignment: center;
|
alignment: center;
|
||||||
IconButton {
|
IconButton {
|
||||||
width: parent.width * 0.45; //min(parent.width * 0.85, 32rem);
|
width: username_box.width;
|
||||||
icon: @image-url("icons/right_arrow.svg");
|
icon: @image-url("icons/right_arrow.svg");
|
||||||
icon-width: 4rem;
|
icon-width: 4rem;
|
||||||
icon-height: self.icon-width/3;
|
icon-height: self.icon-width/3;
|
||||||
@ -380,8 +378,7 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
power_model := VerticalLayout {
|
power_model := VerticalLayout {
|
||||||
height: parent.height * 0.15;
|
height: 15%;
|
||||||
width: parent.width;
|
|
||||||
|
|
||||||
confirm_prompt := Text {
|
confirm_prompt := Text {
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
@ -401,7 +398,6 @@ export component GreeterDisplay inherits Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
width: parent.width;
|
|
||||||
alignment: center;
|
alignment: center;
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@ -416,7 +412,6 @@ export component GreeterDisplay inherits Window {
|
|||||||
private property <string> lastaction;
|
private property <string> lastaction;
|
||||||
|
|
||||||
padding-top: 0.4rem;
|
padding-top: 0.4rem;
|
||||||
width: parent.width;
|
|
||||||
alignment: center;
|
alignment: center;
|
||||||
spacing: 0.5rem;
|
spacing: 0.5rem;
|
||||||
|
|
||||||
@ -477,8 +472,8 @@ export component GreeterDisplay inherits Window {
|
|||||||
|
|
||||||
error_popup := Rectangle {
|
error_popup := Rectangle {
|
||||||
background: rgba(220, 50, 50, 0.85);
|
background: rgba(220, 50, 50, 0.85);
|
||||||
width: min(parent.width * 0.5, 64rem);
|
width: 25%;
|
||||||
height: max(parent.height * 0.125, 6rem);
|
height: 12.5%;
|
||||||
y: (parent.height * 0.025);
|
y: (parent.height * 0.025);
|
||||||
|
|
||||||
opacity: 0.0;
|
opacity: 0.0;
|
||||||
@ -489,8 +484,8 @@ export component GreeterDisplay inherits Window {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
color: rgba(240, 240, 240, 1);
|
color: rgba(240, 240, 240, 1);
|
||||||
width: parent.width * 0.85;
|
width: 85%;
|
||||||
height: parent.height * 0.85;
|
height: 85%;
|
||||||
vertical-alignment: center;
|
vertical-alignment: center;
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user