mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(scheduled-tasks): move recurrence into modal body as a section (#5054)
* improvement(scheduled-tasks): move recurrence into modal body as a section Replace the footer RecurrenceControl (a row of chip dropdowns) with a RecurrenceSection rendered between the prompt body and footer: a "Recurring" Switch toggles one-time vs repeat, and — once on — frequency and end (never, on a date, after N runs) are labeled ChipModalField rows aligned to the modal header/footer gutter. Toggling Recurring off now preserves the recurrence shape (cadence, end, and a passed-through custom cron) and only sets frequency: 'once', so toggling back on restores a conversationally-authored custom schedule instead of silently rewriting it to daily. Also restore the prompt editor's native scale (text-[15px], -0.015em tracking) so the editor reads the same in the chat input and the task modal body. * fix(scheduled-tasks): clear custom cron when switching frequency away from custom The Recurring toggle restores `frequency: 'custom'` when `recurrence.cron` is truthy, but switching the frequency dropdown away from custom kept the stale cron on the object — so editing a custom-cron task to Daily, then toggling Recurring off and back on, snapped it back to Custom and persisted the old cron. Clear `cron` in the non-custom frequency branches so it is present only while the cadence is genuinely custom (matching the type's "custom only" invariant), making the toggle's restore signal accurate. Also document the unreachable `once` branch in frequencyOptionFor as a type-exhaustiveness fallback (keeps the return type without a cast). * fix(scheduled-tasks): restore the prior cadence when re-enabling recurrence Toggling Recurring off collapsed frequency to 'once' but toggling back on forced 'daily' and cleared weekdays, so pausing a weekly/weekdays/monthly task and re-enabling it silently reset it to daily. Cache the last recurring cadence in a ref (written during render) and reinstate it on toggle-on, so a paused "Weekly on Mon" returns as weekly. This also subsumes the custom-cron restore — the ref remembers 'custom' across the one-time interval — so the toggle no longer special-cases cron. * improvement(scheduled-tasks): compose canonical modal separator, tidy imports Replace the recurrence section's hand-rolled `h-px bg-[var(--border)]` divider with the canonical ChipModalSeparator (now exported from the chip-modal barrel) so the modal's hairline has a single source of truth. Also unify loading.tsx icon imports onto the @/components/emcn barrel. --------- Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
+7
-9
@@ -47,27 +47,25 @@ export interface PlusMenuHandle {
|
||||
/**
|
||||
* Box and typography shared by the textarea and its mirror overlay — both must
|
||||
* produce identical line wrapping so the overlay text sits exactly over the
|
||||
* (transparent) textarea text. The scale is the canonical chip text-field
|
||||
* scale ({@link ChipTextarea}: `text-sm`, default tracking), so the editor
|
||||
* reads identically in the chat input and inside chip modals — one size,
|
||||
* everywhere.
|
||||
* (transparent) textarea text. The scale is the chat input's native prompt
|
||||
* scale (`text-[15px]`, `-0.015em` tracking); the task modal's body inherits it
|
||||
* so the editor reads the same whether it's the chat input or inside the modal.
|
||||
*/
|
||||
const FIELD_MIRROR_CLASSES = cn(
|
||||
'm-0 box-border min-h-[20px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
|
||||
'px-1 py-1 font-body text-sm leading-[20px]'
|
||||
'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
|
||||
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]'
|
||||
)
|
||||
|
||||
/**
|
||||
* The textarea grows to its full content height (`h-auto`, no internal scroll);
|
||||
* the shared scroller clips and scrolls it. Its text is transparent so the
|
||||
* mirror overlay shows through; only the caret paints. The placeholder uses
|
||||
* the canonical `--text-muted`, matching every other chip text field.
|
||||
* mirror overlay shows through; only the caret paints.
|
||||
*/
|
||||
export const TEXTAREA_BASE_CLASSES = cn(
|
||||
FIELD_MIRROR_CLASSES,
|
||||
'block h-auto resize-none overflow-hidden',
|
||||
'text-transparent caret-[var(--text-primary)] outline-none',
|
||||
'placeholder:text-[var(--text-muted)]',
|
||||
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
)
|
||||
|
||||
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { format } from 'date-fns'
|
||||
import { ChipDatePicker, ChipDropdown, ChipInput, RefreshCw } from '@/components/emcn'
|
||||
import type { Recurrence } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
|
||||
|
||||
const WEEKDAY_PRESET = [1, 2, 3, 4, 5]
|
||||
/** Seed count when the user first chooses "ends after N runs". */
|
||||
const DEFAULT_END_AFTER_COUNT = 10
|
||||
|
||||
/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
|
||||
type FrequencyOption = 'once' | 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'custom'
|
||||
|
||||
function isWeekdayPreset(weekdays: number[]): boolean {
|
||||
return (
|
||||
weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d))
|
||||
)
|
||||
}
|
||||
|
||||
/** Collapses a recurrence into the single dropdown value that represents it. */
|
||||
function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
|
||||
if (recurrence.frequency === 'weekly')
|
||||
return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly'
|
||||
return recurrence.frequency
|
||||
}
|
||||
|
||||
interface RecurrenceControlProps {
|
||||
recurrence: Recurrence
|
||||
onChange: (recurrence: Recurrence) => void
|
||||
/** The launch day, so weekly/monthly labels name the weekday and day-of-month. */
|
||||
launchDate: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The repeat + end controls for a scheduled task, modeled on a calendar app's
|
||||
* recurrence row: a frequency preset and — when the task repeats — how it ends
|
||||
* (never, on a date, or after N runs).
|
||||
*/
|
||||
export function RecurrenceControl({ recurrence, onChange, launchDate }: RecurrenceControlProps) {
|
||||
const launch = new Date(`${launchDate}T00:00`)
|
||||
|
||||
const frequencyOptions = [
|
||||
{ value: 'once', label: 'Once' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: `Weekly on ${format(launch, 'EEE')}` },
|
||||
{ value: 'weekdays', label: 'Weekdays' },
|
||||
{ value: 'monthly', label: `Monthly on the ${format(launch, 'do')}` },
|
||||
...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []),
|
||||
]
|
||||
|
||||
const handleFrequencyChange = (value: string) => {
|
||||
const option = value as FrequencyOption
|
||||
switch (option) {
|
||||
case 'once':
|
||||
onChange({ frequency: 'once', weekdays: [], end: { type: 'never' } })
|
||||
return
|
||||
case 'daily':
|
||||
onChange({ ...recurrence, frequency: 'daily', weekdays: [] })
|
||||
return
|
||||
case 'weekly':
|
||||
onChange({ ...recurrence, frequency: 'weekly', weekdays: [launch.getDay()] })
|
||||
return
|
||||
case 'weekdays':
|
||||
onChange({ ...recurrence, frequency: 'weekly', weekdays: [...WEEKDAY_PRESET] })
|
||||
return
|
||||
case 'monthly':
|
||||
onChange({ ...recurrence, frequency: 'monthly', weekdays: [] })
|
||||
return
|
||||
case 'custom':
|
||||
onChange({ ...recurrence, frequency: 'custom' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleEndChange = (value: string) => {
|
||||
if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } })
|
||||
else if (value === 'on')
|
||||
onChange({ ...recurrence, end: { type: 'on', date: format(launch, 'yyyy-MM-dd') } })
|
||||
else {
|
||||
const count = recurrence.end.type === 'after' ? recurrence.end.count : DEFAULT_END_AFTER_COUNT
|
||||
onChange({ ...recurrence, end: { type: 'after', count } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<ChipDropdown
|
||||
leftIcon={RefreshCw}
|
||||
value={frequencyOptionFor(recurrence)}
|
||||
options={frequencyOptions}
|
||||
onChange={handleFrequencyChange}
|
||||
matchTriggerWidth={false}
|
||||
flush
|
||||
/>
|
||||
|
||||
{recurrence.frequency !== 'once' && (
|
||||
<ChipDropdown
|
||||
value={recurrence.end.type}
|
||||
options={[
|
||||
{ value: 'never', label: 'No end' },
|
||||
{ value: 'on', label: 'Ends on' },
|
||||
{ value: 'after', label: 'Ends after' },
|
||||
]}
|
||||
onChange={handleEndChange}
|
||||
matchTriggerWidth={false}
|
||||
flush
|
||||
/>
|
||||
)}
|
||||
|
||||
{recurrence.frequency !== 'once' && recurrence.end.type === 'on' && (
|
||||
<ChipDatePicker
|
||||
value={recurrence.end.date}
|
||||
onChange={(date) => onChange({ ...recurrence, end: { type: 'on', date } })}
|
||||
flush
|
||||
/>
|
||||
)}
|
||||
|
||||
{recurrence.frequency !== 'once' && recurrence.end.type === 'after' && (
|
||||
<ChipInput
|
||||
className='w-[120px]'
|
||||
inputMode='numeric'
|
||||
value={String(recurrence.end.count)}
|
||||
onChange={(event) => {
|
||||
const count = Math.max(1, Math.floor(Number(event.target.value) || 1))
|
||||
onChange({ ...recurrence, end: { type: 'after', count } })
|
||||
}}
|
||||
endAdornment={<span className='text-[var(--text-muted)] text-caption'>runs</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { ChipDatePicker, ChipModalField, ChipModalSeparator, Switch } from '@/components/emcn'
|
||||
import type {
|
||||
Recurrence,
|
||||
RecurrenceFrequency,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/recurrence'
|
||||
|
||||
const WEEKDAY_PRESET = [1, 2, 3, 4, 5]
|
||||
/** Seed count when the user first chooses "ends after N runs". */
|
||||
const DEFAULT_END_AFTER_COUNT = 10
|
||||
/** Cadence a task falls back to when the user first flips on recurrence. */
|
||||
const DEFAULT_RECURRING_FREQUENCY = 'daily'
|
||||
|
||||
/** The frequency presets the dropdown authors, keyed by a synthetic option value. */
|
||||
type FrequencyOption = 'daily' | 'weekly' | 'weekdays' | 'monthly' | 'custom'
|
||||
|
||||
function isWeekdayPreset(weekdays: number[]): boolean {
|
||||
return (
|
||||
weekdays.length === WEEKDAY_PRESET.length && WEEKDAY_PRESET.every((d) => weekdays.includes(d))
|
||||
)
|
||||
}
|
||||
|
||||
/** Collapses a recurring recurrence into the single dropdown value that represents it. */
|
||||
function frequencyOptionFor(recurrence: Recurrence): FrequencyOption {
|
||||
if (recurrence.frequency === 'weekly')
|
||||
return isWeekdayPreset(recurrence.weekdays) ? 'weekdays' : 'weekly'
|
||||
// Exhaustiveness fallback: callers gate on `isRecurring`, so `once` never
|
||||
// reaches here at runtime, but the dropdown can't represent it — mapping it to
|
||||
// a recurring default keeps the return type `FrequencyOption` without a cast.
|
||||
if (recurrence.frequency === 'once') return DEFAULT_RECURRING_FREQUENCY
|
||||
return recurrence.frequency
|
||||
}
|
||||
|
||||
interface RecurrenceSectionProps {
|
||||
recurrence: Recurrence
|
||||
onChange: (recurrence: Recurrence) => void
|
||||
/** The launch day, so weekly/monthly labels name the weekday and day-of-month. */
|
||||
launchDate: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The repeat + end controls for a scheduled task, rendered as a body section
|
||||
* below the prompt: a "Recurring" {@link Switch} that toggles a one-time launch
|
||||
* into a repeat, and — once on — the frequency preset and how it ends (never, on
|
||||
* a date, or after N runs).
|
||||
*
|
||||
* Composed as a sibling between the prompt body and footer; it owns its own
|
||||
* leading separator and mirrors {@link ChipModalBody}'s spacing
|
||||
* (`gap-4 px-2 pt-4 pb-4.5`) so every {@link ChipModalField} lands at the same
|
||||
* effective `px-4` as the modal header/footer — no changes to the `ChipModal`
|
||||
* primitives.
|
||||
*/
|
||||
export function RecurrenceSection({ recurrence, onChange, launchDate }: RecurrenceSectionProps) {
|
||||
/**
|
||||
* The cadence to reinstate when recurrence is toggled back on. Toggling off
|
||||
* collapses `frequency` to `once`, dropping which preset was active, so the
|
||||
* last recurring cadence is cached here and restored — a paused "Weekly on
|
||||
* Mon" returns as weekly, not silently reset to daily. Written during render
|
||||
* (an idempotent cache), so it is current before the toggle handler reads it.
|
||||
*/
|
||||
const lastRecurringFrequency = useRef<RecurrenceFrequency>(DEFAULT_RECURRING_FREQUENCY)
|
||||
if (recurrence.frequency !== 'once') lastRecurringFrequency.current = recurrence.frequency
|
||||
|
||||
const launch = new Date(`${launchDate}T00:00`)
|
||||
const isRecurring = recurrence.frequency !== 'once'
|
||||
|
||||
const frequencyOptions = [
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: `Weekly on ${format(launch, 'EEE')}` },
|
||||
{ value: 'weekdays', label: 'Weekdays' },
|
||||
{ value: 'monthly', label: `Monthly on the ${format(launch, 'do')}` },
|
||||
...(recurrence.frequency === 'custom' ? [{ value: 'custom', label: 'Custom' }] : []),
|
||||
]
|
||||
|
||||
/**
|
||||
* Flips the one-time launch into a repeat and back. Toggling off keeps the
|
||||
* recurrence shape (weekdays, end, and a passed-through `custom` cron) on the
|
||||
* object and only collapses `frequency` to `once`; toggling back on reinstates
|
||||
* the remembered cadence, so neither a weekly preset nor a conversationally
|
||||
* authored custom cron is silently rewritten to daily.
|
||||
*/
|
||||
const handleRecurringToggle = (checked: boolean) => {
|
||||
onChange({ ...recurrence, frequency: checked ? lastRecurringFrequency.current : 'once' })
|
||||
}
|
||||
|
||||
const handleFrequencyChange = (value: string) => {
|
||||
const option = value as FrequencyOption
|
||||
switch (option) {
|
||||
case 'daily':
|
||||
onChange({ ...recurrence, frequency: 'daily', weekdays: [], cron: undefined })
|
||||
return
|
||||
case 'weekly':
|
||||
onChange({
|
||||
...recurrence,
|
||||
frequency: 'weekly',
|
||||
weekdays: [launch.getDay()],
|
||||
cron: undefined,
|
||||
})
|
||||
return
|
||||
case 'weekdays':
|
||||
onChange({
|
||||
...recurrence,
|
||||
frequency: 'weekly',
|
||||
weekdays: [...WEEKDAY_PRESET],
|
||||
cron: undefined,
|
||||
})
|
||||
return
|
||||
case 'monthly':
|
||||
onChange({ ...recurrence, frequency: 'monthly', weekdays: [], cron: undefined })
|
||||
return
|
||||
case 'custom':
|
||||
onChange({ ...recurrence, frequency: 'custom' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleEndChange = (value: string) => {
|
||||
if (value === 'never') onChange({ ...recurrence, end: { type: 'never' } })
|
||||
else if (value === 'on')
|
||||
onChange({ ...recurrence, end: { type: 'on', date: format(launch, 'yyyy-MM-dd') } })
|
||||
else {
|
||||
const count = recurrence.end.type === 'after' ? recurrence.end.count : DEFAULT_END_AFTER_COUNT
|
||||
onChange({ ...recurrence, end: { type: 'after', count } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<ChipModalSeparator />
|
||||
<div className='flex flex-col gap-4 px-2 pt-4 pb-4.5'>
|
||||
<ChipModalField type='custom' title='Recurring'>
|
||||
<Switch checked={isRecurring} onCheckedChange={handleRecurringToggle} />
|
||||
</ChipModalField>
|
||||
|
||||
{isRecurring && (
|
||||
<>
|
||||
<ChipModalField
|
||||
type='dropdown'
|
||||
title='Frequency'
|
||||
value={frequencyOptionFor(recurrence)}
|
||||
options={frequencyOptions}
|
||||
onChange={handleFrequencyChange}
|
||||
/>
|
||||
|
||||
<ChipModalField
|
||||
type='dropdown'
|
||||
title='Ends'
|
||||
value={recurrence.end.type}
|
||||
options={[
|
||||
{ value: 'never', label: 'No end' },
|
||||
{ value: 'on', label: 'Ends on' },
|
||||
{ value: 'after', label: 'Ends after' },
|
||||
]}
|
||||
onChange={handleEndChange}
|
||||
/>
|
||||
|
||||
{recurrence.end.type === 'on' && (
|
||||
<ChipModalField type='custom' title='End date'>
|
||||
<ChipDatePicker
|
||||
value={recurrence.end.date}
|
||||
onChange={(date) => onChange({ ...recurrence, end: { type: 'on', date } })}
|
||||
fullWidth
|
||||
/>
|
||||
</ChipModalField>
|
||||
)}
|
||||
|
||||
{recurrence.end.type === 'after' && (
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Number of runs'
|
||||
value={String(recurrence.end.count)}
|
||||
onChange={(value) => {
|
||||
const count = Math.max(1, Math.floor(Number(value) || 1))
|
||||
onChange({ ...recurrence, end: { type: 'after', count } })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
-18
@@ -18,7 +18,7 @@ import {
|
||||
PromptEditor,
|
||||
usePromptEditor,
|
||||
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
|
||||
import { RecurrenceControl } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-control'
|
||||
import { RecurrenceSection } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal/recurrence-section'
|
||||
import type { CalendarSlot } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar'
|
||||
import {
|
||||
DEFAULT_RECURRENCE,
|
||||
@@ -117,10 +117,10 @@ interface TaskModalProps {
|
||||
|
||||
/**
|
||||
* The "schedule a task" modal, shared by create (blank, or pre-filled from a
|
||||
* duplicate) and edit (seeded from a task's schedule). The body is one prompt
|
||||
* surface — the chat input's editor, so `@` mentions resources and `/` invokes
|
||||
* skills exactly like talking to Sim — and the footer carries the recurrence,
|
||||
* launch date/time, and (edit only) Delete.
|
||||
* duplicate) and edit (seeded from a task's schedule). The body is the chat
|
||||
* input's editor — so `@` mentions resources and `/` invokes skills exactly like
|
||||
* talking to Sim — followed by the recurrence section; the footer carries the
|
||||
* launch date/time and (edit only) Delete.
|
||||
*/
|
||||
export function TaskModal({
|
||||
open,
|
||||
@@ -296,10 +296,11 @@ function TaskModalContent({
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer secondary actions. Delete is disabled while `submitting` because it
|
||||
* bypasses the dismiss guard — it closes the modal via `closeTask`, not the
|
||||
* guarded `onOpenChange` — so without the lock an in-flight edit and a delete
|
||||
* could run against the same task at once.
|
||||
* Footer secondary actions — the launch date/time pickers and (edit only)
|
||||
* Delete. Delete is disabled while `submitting` because it bypasses the
|
||||
* dismiss guard — it closes the modal via `closeTask`, not the guarded
|
||||
* `onOpenChange` — so without the lock an in-flight edit and a delete could
|
||||
* run against the same task at once. Recurrence lives in the body, not here.
|
||||
*/
|
||||
const secondaryActions: ChipModalFooterSlotAction[] = [
|
||||
...(edit && onRequestDelete
|
||||
@@ -312,15 +313,6 @@ function TaskModalContent({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
custom: (
|
||||
<RecurrenceControl
|
||||
recurrence={recurrence}
|
||||
onChange={setRecurrence}
|
||||
launchDate={launchDate}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ custom: <ChipDatePicker value={launchDate} onChange={editLaunchDate} flush /> },
|
||||
{ custom: <ChipTimePicker value={launchTime} onChange={editLaunchTime} flush /> },
|
||||
]
|
||||
@@ -338,6 +330,7 @@ function TaskModalContent({
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</ChipModalPromptBody>
|
||||
<RecurrenceSection recurrence={recurrence} onChange={setRecurrence} launchDate={launchDate} />
|
||||
<ChipModalFooter
|
||||
onCancel={close}
|
||||
cancelDisabled={submitting}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from '@/components/emcn'
|
||||
import { Calendar } from '@/components/emcn/icons'
|
||||
import { Calendar, Plus } from '@/components/emcn'
|
||||
import {
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
|
||||
@@ -62,8 +62,12 @@ import { Loader } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
/** Shared inset separator used by the header and footer edges. */
|
||||
function ChipModalSeparator({ className }: { className?: string }) {
|
||||
/**
|
||||
* The modal's hairline divider — used by the header and footer edges, and
|
||||
* exported so body sections (e.g. a settings band below a prompt) can draw the
|
||||
* same line instead of re-deriving the `h-px bg-[var(--border)]` string.
|
||||
*/
|
||||
export function ChipModalSeparator({ className }: { className?: string }) {
|
||||
return <div className={cn('h-px bg-[var(--border)]', className)} />
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export {
|
||||
ChipModalPromptBody,
|
||||
type ChipModalPromptBodyProps,
|
||||
type ChipModalProps,
|
||||
ChipModalSeparator,
|
||||
type ChipModalTab,
|
||||
ChipModalTabs,
|
||||
type ChipModalTabsProps,
|
||||
|
||||
Reference in New Issue
Block a user