Feature 3: Read-only Google Calendar panel
- CalendarEvent model with summary, start, end, location - New scope calendar.readonly in SCOPES - fetch_upcoming_events() in ApiClient (15 events, next 7 days) - Focus::Calendar variant, Tab cycle includes Calendar - Body layout: left column split into Tasks (flex) + Calendar (8) - render_calendar_panel with day headers and scroll - refresh_calendar() called on initial sync and Ctrl+R - Up/Down scroll Calendar panel when focused
This commit is contained in:
+14
-3
@@ -23,6 +23,7 @@ pub struct SyncStats {
|
|||||||
pub struct App {
|
pub struct App {
|
||||||
pub lists: Vec<TaskList>,
|
pub lists: Vec<TaskList>,
|
||||||
pub tasks: Vec<Task>,
|
pub tasks: Vec<Task>,
|
||||||
|
pub calendar_events: Vec<CalendarEvent>,
|
||||||
pub selected_list: usize,
|
pub selected_list: usize,
|
||||||
pub selected_task: usize,
|
pub selected_task: usize,
|
||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
@@ -38,6 +39,7 @@ pub struct App {
|
|||||||
pub task_list_scroll: u16,
|
pub task_list_scroll: u16,
|
||||||
pub detail_scroll: u16,
|
pub detail_scroll: u16,
|
||||||
pub notes_scroll: u16,
|
pub notes_scroll: u16,
|
||||||
|
pub calendar_scroll: u16,
|
||||||
pub db: Arc<Db>,
|
pub db: Arc<Db>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub api_client: Arc<ApiClient>,
|
pub api_client: Arc<ApiClient>,
|
||||||
@@ -66,7 +68,7 @@ pub enum SyncCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self {
|
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>, _calendar_events_shared: Arc<tokio::sync::Mutex<Vec<CalendarEvent>>>) -> Self {
|
||||||
let has_token = api_client.has_token();
|
let has_token = api_client.has_token();
|
||||||
let (auth_tx, auth_rx) = std_mpsc::channel();
|
let (auth_tx, auth_rx) = std_mpsc::channel();
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ impl App {
|
|||||||
Self {
|
Self {
|
||||||
lists,
|
lists,
|
||||||
tasks,
|
tasks,
|
||||||
|
calendar_events: Vec::new(),
|
||||||
selected_list: 0,
|
selected_list: 0,
|
||||||
selected_task: 0,
|
selected_task: 0,
|
||||||
focus: Focus::Tabs,
|
focus: Focus::Tabs,
|
||||||
@@ -106,6 +109,7 @@ impl App {
|
|||||||
task_list_scroll: 0,
|
task_list_scroll: 0,
|
||||||
detail_scroll: 0,
|
detail_scroll: 0,
|
||||||
notes_scroll: 0,
|
notes_scroll: 0,
|
||||||
|
calendar_scroll: 0,
|
||||||
db,
|
db,
|
||||||
api_client,
|
api_client,
|
||||||
needs_auth: !has_token,
|
needs_auth: !has_token,
|
||||||
@@ -282,7 +286,8 @@ impl App {
|
|||||||
self.focus = match self.focus {
|
self.focus = match self.focus {
|
||||||
Focus::Tabs => Focus::TaskList,
|
Focus::Tabs => Focus::TaskList,
|
||||||
Focus::TaskList => Focus::Detail,
|
Focus::TaskList => Focus::Detail,
|
||||||
Focus::Detail => Focus::Tabs,
|
Focus::Detail => Focus::Calendar,
|
||||||
|
Focus::Calendar => Focus::Tabs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||||
@@ -305,6 +310,9 @@ impl App {
|
|||||||
Focus::Detail => {
|
Focus::Detail => {
|
||||||
self.detail_scroll = self.detail_scroll.saturating_sub(1);
|
self.detail_scroll = self.detail_scroll.saturating_sub(1);
|
||||||
}
|
}
|
||||||
|
Focus::Calendar => {
|
||||||
|
self.calendar_scroll = self.calendar_scroll.saturating_sub(1);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
KeyCode::Down => match self.focus {
|
KeyCode::Down => match self.focus {
|
||||||
@@ -317,6 +325,9 @@ impl App {
|
|||||||
Focus::Detail => {
|
Focus::Detail => {
|
||||||
self.detail_scroll += 1;
|
self.detail_scroll += 1;
|
||||||
}
|
}
|
||||||
|
Focus::Calendar => {
|
||||||
|
self.calendar_scroll += 1;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
@@ -729,7 +740,7 @@ impl App {
|
|||||||
self.load_tasks();
|
self.load_tasks();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Focus::TaskList | Focus::Detail => {
|
Focus::TaskList | Focus::Detail | Focus::Calendar => {
|
||||||
if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
|
if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
|
||||||
let task = &self.tasks[self.selected_task];
|
let task = &self.tasks[self.selected_task];
|
||||||
let task_id = task.id.clone();
|
let task_id = task.id.clone();
|
||||||
|
|||||||
@@ -46,3 +46,11 @@ pub struct SyncQueueItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub const MAX_SYNC_RETRIES: i32 = 3;
|
pub const MAX_SYNC_RETRIES: i32 = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CalendarEvent {
|
||||||
|
pub summary: String,
|
||||||
|
pub start: Option<chrono::NaiveDateTime>,
|
||||||
|
pub end: Option<chrono::NaiveDateTime>,
|
||||||
|
pub location: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ pub struct ApiClient {
|
|||||||
token_path: PathBuf,
|
token_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCOPES: &[&str] = &["https://www.googleapis.com/auth/tasks"];
|
const SCOPES: &[&str] = &[
|
||||||
|
"https://www.googleapis.com/auth/tasks",
|
||||||
|
"https://www.googleapis.com/auth/calendar.readonly",
|
||||||
|
];
|
||||||
|
|
||||||
impl ApiClient {
|
impl ApiClient {
|
||||||
pub async fn new(secret_path: impl AsRef<Path>) -> Result<Self, ApiError> {
|
pub async fn new(secret_path: impl AsRef<Path>) -> Result<Self, ApiError> {
|
||||||
@@ -455,4 +458,60 @@ impl ApiClient {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_upcoming_events(&self, max_results: u32) -> Result<Vec<CalendarEvent>, ApiError> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let week_later = now + chrono::Duration::days(7);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get("https://www.googleapis.com/calendar/v3/calendars/primary/events")
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.query(&[
|
||||||
|
("orderBy", "startTime"),
|
||||||
|
("singleEvents", "true"),
|
||||||
|
("timeMin", &now.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
|
||||||
|
("timeMax", &week_later.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
|
||||||
|
("maxResults", &max_results.to_string()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ApiError::Api(format!("Calendar fetch failed: {} - {}", status, body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp.json().await
|
||||||
|
.map_err(|e| ApiError::Api(format!("Calendar JSON parse error: {}", e)))?;
|
||||||
|
|
||||||
|
let items = body["items"].as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter().filter_map(|item| {
|
||||||
|
let summary = item["summary"].as_str()?.to_string();
|
||||||
|
let start = parse_calendar_time(&item["start"]);
|
||||||
|
let end = parse_calendar_time(&item["end"]);
|
||||||
|
let location = item["location"].as_str().map(|s| s.to_string());
|
||||||
|
Some(CalendarEvent { summary, start, end, location })
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_calendar_time(obj: &serde_json::Value) -> Option<chrono::NaiveDateTime> {
|
||||||
|
if let Some(dt) = obj["dateTime"].as_str() {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(dt).ok().map(|d| d.naive_local())
|
||||||
|
} else if let Some(d) = obj["date"].as_str() {
|
||||||
|
chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()
|
||||||
|
.map(|date| date.and_hms_opt(0, 0, 0).unwrap())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -83,19 +83,21 @@ fn main() -> io::Result<()> {
|
|||||||
|
|
||||||
let network_status = Arc::new(Mutex::new(NetworkStatus::Online));
|
let network_status = Arc::new(Mutex::new(NetworkStatus::Online));
|
||||||
let sync_stats = Arc::new(Mutex::new(SyncStats::default()));
|
let sync_stats = Arc::new(Mutex::new(SyncStats::default()));
|
||||||
|
let calendar_events_shared = Arc::new(Mutex::new(Vec::<CalendarEvent>::new()));
|
||||||
let (sync_tx, mut sync_rx) = tokio::sync::mpsc::channel::<SyncCommand>(32);
|
let (sync_tx, mut sync_rx) = tokio::sync::mpsc::channel::<SyncCommand>(32);
|
||||||
|
|
||||||
let mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone());
|
let mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone(), calendar_events_shared.clone());
|
||||||
|
|
||||||
let network_clone = network_status.clone();
|
let network_clone = network_status.clone();
|
||||||
let stats_clone = sync_stats.clone();
|
let stats_clone = sync_stats.clone();
|
||||||
let db_clone = db.clone();
|
let db_clone = db.clone();
|
||||||
let api_clone = api_client.clone();
|
let api_clone = api_client.clone();
|
||||||
|
let cal_clone = calendar_events_shared.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(async move {
|
rt.block_on(async move {
|
||||||
run_sync_engine(db_clone, api_clone, network_clone, stats_clone, &mut sync_rx).await;
|
run_sync_engine(db_clone, api_clone, network_clone, stats_clone, cal_clone, &mut sync_rx).await;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,12 +125,18 @@ fn main() -> io::Result<()> {
|
|||||||
app.sync_stats = guard.clone();
|
app.sync_stats = guard.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let guard = calendar_events_shared.blocking_lock();
|
||||||
|
app.calendar_events = guard.clone();
|
||||||
|
}
|
||||||
|
|
||||||
// Reload lists/tasks if sync engine changed data in background
|
// Reload lists/tasks if sync engine changed data in background
|
||||||
app.refresh_if_needed();
|
app.refresh_if_needed();
|
||||||
|
|
||||||
let view = AppView {
|
let view = AppView {
|
||||||
lists: &app.lists,
|
lists: &app.lists,
|
||||||
tasks: &app.tasks,
|
tasks: &app.tasks,
|
||||||
|
calendar_events: &app.calendar_events,
|
||||||
selected_list: app.selected_list,
|
selected_list: app.selected_list,
|
||||||
selected_task: app.selected_task,
|
selected_task: app.selected_task,
|
||||||
focus: app.focus.clone(),
|
focus: app.focus.clone(),
|
||||||
@@ -142,6 +150,7 @@ fn main() -> io::Result<()> {
|
|||||||
task_list_scroll: app.task_list_scroll,
|
task_list_scroll: app.task_list_scroll,
|
||||||
detail_scroll: app.detail_scroll,
|
detail_scroll: app.detail_scroll,
|
||||||
notes_scroll: app.notes_scroll,
|
notes_scroll: app.notes_scroll,
|
||||||
|
calendar_scroll: app.calendar_scroll,
|
||||||
auth_error: app.auth_error.as_deref(),
|
auth_error: app.auth_error.as_deref(),
|
||||||
sync_stats: &app.sync_stats,
|
sync_stats: &app.sync_stats,
|
||||||
};
|
};
|
||||||
@@ -165,6 +174,7 @@ async fn run_sync_engine(
|
|||||||
api: Arc<ApiClient>,
|
api: Arc<ApiClient>,
|
||||||
network_status: Arc<Mutex<NetworkStatus>>,
|
network_status: Arc<Mutex<NetworkStatus>>,
|
||||||
sync_stats: Arc<Mutex<SyncStats>>,
|
sync_stats: Arc<Mutex<SyncStats>>,
|
||||||
|
calendar_events: Arc<Mutex<Vec<CalendarEvent>>>,
|
||||||
rx: &mut tokio::sync::mpsc::Receiver<SyncCommand>,
|
rx: &mut tokio::sync::mpsc::Receiver<SyncCommand>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
@@ -175,9 +185,11 @@ async fn run_sync_engine(
|
|||||||
Some(SyncCommand::FullSync) => {
|
Some(SyncCommand::FullSync) => {
|
||||||
push_sync(&db, &api, &network_status, &sync_stats).await;
|
push_sync(&db, &api, &network_status, &sync_stats).await;
|
||||||
pull_sync(&db, &api, &network_status, &sync_stats, true).await;
|
pull_sync(&db, &api, &network_status, &sync_stats, true).await;
|
||||||
|
refresh_calendar(&api, &calendar_events, &network_status).await;
|
||||||
}
|
}
|
||||||
Some(SyncCommand::InitialSync) => {
|
Some(SyncCommand::InitialSync) => {
|
||||||
run_initial_sync(&db, &api, &network_status, &sync_stats).await;
|
run_initial_sync(&db, &api, &network_status, &sync_stats).await;
|
||||||
|
refresh_calendar(&api, &calendar_events, &network_status).await;
|
||||||
}
|
}
|
||||||
Some(SyncCommand::Shutdown) | None => break,
|
Some(SyncCommand::Shutdown) | None => break,
|
||||||
}
|
}
|
||||||
@@ -386,3 +398,20 @@ async fn pull_sync(
|
|||||||
stats.tasks_changed = total_tasks;
|
stats.tasks_changed = total_tasks;
|
||||||
stats.version += 1;
|
stats.version += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn refresh_calendar(
|
||||||
|
api: &Arc<ApiClient>,
|
||||||
|
events_shared: &Arc<Mutex<Vec<CalendarEvent>>>,
|
||||||
|
network_status: &Arc<Mutex<NetworkStatus>>,
|
||||||
|
) {
|
||||||
|
match api.fetch_upcoming_events(15).await {
|
||||||
|
Ok(events) => {
|
||||||
|
let mut guard = events_shared.lock().await;
|
||||||
|
*guard = events;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[task_app] Calendar fetch failed: {}", e);
|
||||||
|
*network_status.lock().await = NetworkStatus::Offline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -595,6 +595,77 @@ pub fn render_device_auth_popup(
|
|||||||
frame.render_widget(paragraph, popup_area);
|
frame.render_widget(paragraph, popup_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_calendar_panel(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
events: &[CalendarEvent],
|
||||||
|
focused: bool,
|
||||||
|
scroll: u16,
|
||||||
|
) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
|
||||||
|
.title(" Calendar ")
|
||||||
|
.title_alignment(Alignment::Left);
|
||||||
|
|
||||||
|
if events.is_empty() {
|
||||||
|
let paragraph = Paragraph::new(Text::from(Line::from(Span::styled(
|
||||||
|
" No upcoming events ",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
))))
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
let mut current_date: Option<chrono::NaiveDate> = None;
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
if let Some(start) = event.start {
|
||||||
|
let event_date = start.date();
|
||||||
|
if Some(event_date) != current_date {
|
||||||
|
current_date = Some(event_date);
|
||||||
|
let day_header = format!(
|
||||||
|
" --- {} {} --- ",
|
||||||
|
event_date.format("%A"),
|
||||||
|
event_date.format("%d/%m"),
|
||||||
|
);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
day_header,
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_str = start.format("%H:%M").to_string();
|
||||||
|
let summary = &event.summary;
|
||||||
|
let line_text = if summary.len() > 30 {
|
||||||
|
format!(" {} {:.30}…", time_str, summary)
|
||||||
|
} else {
|
||||||
|
format!(" {} {}", time_str, summary)
|
||||||
|
};
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
line_text,
|
||||||
|
Style::default().fg(DETAIL_COLOR),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner_h = (area.height as usize).saturating_sub(2);
|
||||||
|
let visible_lines: Vec<Line> = lines
|
||||||
|
.iter()
|
||||||
|
.skip(scroll as usize)
|
||||||
|
.take(inner_h)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Text::from(visible_lines))
|
||||||
|
.block(block)
|
||||||
|
.scroll((0, 0));
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
/// Simple word wrap: splits text at word boundaries to fit max_width chars per line
|
/// Simple word wrap: splits text at word boundaries to fit max_width chars per line
|
||||||
fn textwrap(text: &str, max_width: usize) -> Vec<String> {
|
fn textwrap(text: &str, max_width: usize) -> Vec<String> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|||||||
+18
-1
@@ -12,6 +12,7 @@ pub enum Focus {
|
|||||||
Tabs,
|
Tabs,
|
||||||
TaskList,
|
TaskList,
|
||||||
Detail,
|
Detail,
|
||||||
|
Calendar,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -46,6 +47,8 @@ pub struct AppView<'a> {
|
|||||||
pub task_list_scroll: u16,
|
pub task_list_scroll: u16,
|
||||||
pub detail_scroll: u16,
|
pub detail_scroll: u16,
|
||||||
pub notes_scroll: u16,
|
pub notes_scroll: u16,
|
||||||
|
pub calendar_events: &'a [CalendarEvent],
|
||||||
|
pub calendar_scroll: u16,
|
||||||
pub auth_error: Option<&'a str>,
|
pub auth_error: Option<&'a str>,
|
||||||
pub sync_stats: &'a SyncStats,
|
pub sync_stats: &'a SyncStats,
|
||||||
}
|
}
|
||||||
@@ -74,16 +77,30 @@ pub fn draw(frame: &mut Frame, view: AppView) {
|
|||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
.split(body_area);
|
.split(body_area);
|
||||||
|
|
||||||
|
let left_col = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(8)])
|
||||||
|
.split(body_layout[0]);
|
||||||
|
|
||||||
let is_task_list_focused = view.focus == Focus::TaskList;
|
let is_task_list_focused = view.focus == Focus::TaskList;
|
||||||
render_task_list(
|
render_task_list(
|
||||||
frame,
|
frame,
|
||||||
body_layout[0],
|
left_col[0],
|
||||||
view.tasks,
|
view.tasks,
|
||||||
view.selected_task,
|
view.selected_task,
|
||||||
is_task_list_focused,
|
is_task_list_focused,
|
||||||
view.task_list_scroll,
|
view.task_list_scroll,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let is_calendar_focused = view.focus == Focus::Calendar;
|
||||||
|
render_calendar_panel(
|
||||||
|
frame,
|
||||||
|
left_col[1],
|
||||||
|
view.calendar_events,
|
||||||
|
is_calendar_focused,
|
||||||
|
view.calendar_scroll,
|
||||||
|
);
|
||||||
|
|
||||||
let is_detail_focused = view.focus == Focus::Detail;
|
let is_detail_focused = view.focus == Focus::Detail;
|
||||||
render_detail(
|
render_detail(
|
||||||
frame,
|
frame,
|
||||||
|
|||||||
Reference in New Issue
Block a user