Add to this week and favorite (Feature 15) #147
@@ -1,6 +1,6 @@
|
|||||||
import { useId, useState } from 'react'
|
import { useId, useState } from 'react'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { usePlan, useSwapDay, type PlanDinner, type Swap } from '../plan/api.ts'
|
import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts'
|
||||||
import { cooking } from '../plan/PlanPage.tsx'
|
import { cooking } from '../plan/PlanPage.tsx'
|
||||||
import swapStyles from '../plan/SwapSheet.module.css'
|
import swapStyles from '../plan/SwapSheet.module.css'
|
||||||
import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||||
@@ -41,13 +41,14 @@ type WeekDaysProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WeekDays is the seven days of the week starting on week, each with its
|
// WeekDays is the seven days of the week starting on week, each with its
|
||||||
// dinner; choosing one swaps the dinner in through the Plan's own swap
|
// dinner; choosing one swaps the dinner in as the Draft board swaps a day
|
||||||
// (useSwapDay). A week with no plan offers to draft it on the Draft board:
|
// (useWeekSwap). A week with no plan offers to draft it on the Draft board:
|
||||||
// nothing here makes a plan up. A dinner already on the week can't be added
|
// nothing here makes a plan up. A dinner already on the week can't be added
|
||||||
// again, nor can one while a change to the week is still being saved.
|
// again, nor can one while a swap of the week is still being saved or the
|
||||||
|
// week is being pushed to Calendar.
|
||||||
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
|
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
|
||||||
const plan = usePlan(week)
|
const plan = usePlan(week)
|
||||||
const swap = useSwapDay(week)
|
const { swap, push, refused, swapIn } = useWeekSwap(week)
|
||||||
|
|
||||||
if (plan.isError) {
|
if (plan.isError) {
|
||||||
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
|
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
|
||||||
@@ -74,10 +75,10 @@ function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
|
|||||||
|
|
||||||
const days = plan.data.days
|
const days = plan.data.days
|
||||||
const planned = days.find((day) => day.dinner.id === dinner.id)
|
const planned = days.find((day) => day.dinner.id === dinner.id)
|
||||||
const choosable = planned === undefined && !swap.pending
|
const choosable = planned === undefined && !refused
|
||||||
function add(date: string) {
|
function add(date: string) {
|
||||||
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
|
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
|
||||||
swap.mutate(chosen)
|
swapIn(chosen)
|
||||||
onAdd({ week, which, swap: chosen })
|
onAdd({ week, which, swap: chosen })
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -86,8 +87,10 @@ function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
|
|||||||
<p className={swapStyles.empty}>
|
<p className={swapStyles.empty}>
|
||||||
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
|
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
|
||||||
</p>
|
</p>
|
||||||
|
) : swap.pending ? (
|
||||||
|
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
|
||||||
) : (
|
) : (
|
||||||
swap.pending && <p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
|
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
|
||||||
)}
|
)}
|
||||||
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
|
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
|||||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
|
import { PlanPage } from '../plan/PlanPage.tsx'
|
||||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||||
|
|
||||||
const DINNER = '/api/v1/dinners/d1'
|
const DINNER = '/api/v1/dinners/d1'
|
||||||
@@ -66,7 +67,7 @@ function planBody(weekStart: string, names: string[], ids = names.map((_, i) =>
|
|||||||
week_start: weekStart,
|
week_start: weekStart,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
ai_note: null,
|
ai_note: null,
|
||||||
calendar_synced_at: null,
|
calendar_synced_at: null as string | null,
|
||||||
days: names.map((name, i) => ({
|
days: names.map((name, i) => ({
|
||||||
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||||
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
|
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
|
||||||
@@ -127,14 +128,15 @@ function deferred() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||||
// the bowls' detail screen, with a probe for the Plan.
|
// the bowls' detail screen, with plan for the Plan: a probe unless told
|
||||||
async function renderPage() {
|
// otherwise. It answers with the router.
|
||||||
|
async function renderPage(plan = <h1>Plan probe</h1>) {
|
||||||
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
await api.loadMe()
|
await api.loadMe()
|
||||||
const router = createMemoryRouter(
|
const router = createMemoryRouter(
|
||||||
[
|
[
|
||||||
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
|
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
|
||||||
{ path: '/plan', element: <h1>Plan probe</h1> },
|
{ path: '/plan', element: plan },
|
||||||
],
|
],
|
||||||
{ initialEntries: ['/dinner/d1'] },
|
{ initialEntries: ['/dinner/d1'] },
|
||||||
)
|
)
|
||||||
@@ -146,6 +148,7 @@ async function renderPage() {
|
|||||||
</ApiProvider>,
|
</ApiProvider>,
|
||||||
)
|
)
|
||||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||||
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
// openSheet opens Add to this week and waits for the week's days.
|
// openSheet opens Add to this week and waits for the week's days.
|
||||||
@@ -289,6 +292,70 @@ describe('Add to this week', () => {
|
|||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
|
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => {
|
||||||
|
let plan = planBody('2026-10-05', THIS_NAMES)
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||||
|
[`POST ${THIS_WEEK}/push-calendar`]: () => {
|
||||||
|
plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||||
|
return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } })
|
||||||
|
},
|
||||||
|
[`PUT ${THURSDAY}`]: () => {
|
||||||
|
plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||||
|
return jsonResponse(200, plan)
|
||||||
|
},
|
||||||
|
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
|
||||||
|
})
|
||||||
|
const router = await renderPage(<PlanPage />)
|
||||||
|
|
||||||
|
// The week is pushed on the Plan, which says its Supply run was made.
|
||||||
|
await act(() => router.navigate('/plan'))
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||||
|
// Coming back from the dinner with nothing swapped, it still says so.
|
||||||
|
await act(() => router.navigate('/dinner/d1'))
|
||||||
|
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||||
|
await act(() => router.navigate('/plan'))
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||||
|
|
||||||
|
// Thursday is swapped from the dinner's Add to this week.
|
||||||
|
await act(() => router.navigate('/dinner/d1'))
|
||||||
|
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||||
|
const { days } = await openSheet()
|
||||||
|
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||||
|
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||||
|
|
||||||
|
// The Plan shows the swapped plan, not changed since its push, and no
|
||||||
|
// longer says the earlier plan's Supply run was made.
|
||||||
|
await act(() => router.navigate('/plan'))
|
||||||
|
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Korean beef bowls')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument()
|
||||||
|
expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`])
|
||||||
|
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a swap while the week is being pushed to Calendar', async () => {
|
||||||
|
const push = deferred()
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||||
|
[`POST ${THIS_WEEK}/push-calendar`]: () => push.response,
|
||||||
|
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
|
||||||
|
})
|
||||||
|
const router = await renderPage(<PlanPage />)
|
||||||
|
await act(() => router.navigate('/plan'))
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||||
|
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
|
||||||
|
|
||||||
|
await act(() => router.navigate('/dinner/d1'))
|
||||||
|
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||||
|
const { sheet, days } = await openSheet()
|
||||||
|
expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument()
|
||||||
|
expect(within(days).queryAllByRole('button')).toHaveLength(0)
|
||||||
|
expect(sent(fetchMock, 'PUT')).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('the favorite heart', () => {
|
describe('the favorite heart', () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ChevronLeft, Heart } from 'lucide-react'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router'
|
import { Link, useLocation, useNavigate, useParams } from 'react-router'
|
||||||
import { ApiError } from '../api/errors.ts'
|
import { ApiError } from '../api/errors.ts'
|
||||||
import { planKey, usePlan, useSwapDay } from '../plan/api.ts'
|
import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
|
||||||
import { duration } from '../plan/PlanPage.tsx'
|
import { duration } from '../plan/PlanPage.tsx'
|
||||||
import { weekdayName } from '../plan/week.ts'
|
import { weekdayName } from '../plan/week.ts'
|
||||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||||
@@ -128,13 +128,14 @@ function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddedStatus says what became of a dinner added to a day: being saved,
|
// AddedStatus says what became of a dinner added to a day: being saved,
|
||||||
// added, or why it couldn't be, with Retry, which sends the same swap again.
|
// added, or why it couldn't be, with Retry, which sends the same swap again
|
||||||
// The week's plan stays read while it shows, so a retry finds the day to
|
// as the Draft board would (useWeekSwap), and is not offered while that is
|
||||||
// swap; once added, the plan is read again from the server.
|
// refused. The week's plan stays read while it shows, so a retry finds the
|
||||||
|
// day to swap; once added, the plan is read again from the server.
|
||||||
function AddedStatus({ added }: { added: Added }) {
|
function AddedStatus({ added }: { added: Added }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
usePlan(added.week)
|
usePlan(added.week)
|
||||||
const swap = useSwapDay(added.week)
|
const { swap, refused, swapIn } = useWeekSwap(added.week)
|
||||||
const { date } = added.swap
|
const { date } = added.swap
|
||||||
const done = swap.succeeded === added.swap
|
const done = swap.succeeded === added.swap
|
||||||
const failed = swap.failed?.swap === added.swap ? swap.failed.error : null
|
const failed = swap.failed?.swap === added.swap ? swap.failed.error : null
|
||||||
@@ -145,7 +146,7 @@ function AddedStatus({ added }: { added: Added }) {
|
|||||||
if (failed !== null) {
|
if (failed !== null) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.status}>
|
<div className={styles.status}>
|
||||||
<ErrorBanner onRetry={() => swap.mutate(added.swap)}>
|
<ErrorBanner onRetry={refused ? undefined : () => swapIn(added.swap)}>
|
||||||
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
|
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
|
||||||
</ErrorBanner>
|
</ErrorBanner>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-19
@@ -23,9 +23,8 @@ import {
|
|||||||
parseDraftResult,
|
parseDraftResult,
|
||||||
planKey,
|
planKey,
|
||||||
usePlan,
|
usePlan,
|
||||||
usePushToCalendar,
|
|
||||||
useStartDraft,
|
useStartDraft,
|
||||||
useSwapDay,
|
useWeekSwap,
|
||||||
type DraftResult,
|
type DraftResult,
|
||||||
type Plan,
|
type Plan,
|
||||||
type PlanDay,
|
type PlanDay,
|
||||||
@@ -266,8 +265,7 @@ function WeekBoard({
|
|||||||
const [jobId, setJobId] = useState<string | null>(null)
|
const [jobId, setJobId] = useState<string | null>(null)
|
||||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const swap = useSwapDay(weekStart)
|
const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart)
|
||||||
const push = usePushToCalendar(weekStart)
|
|
||||||
const run = useSupplyRun(weekStart)
|
const run = useSupplyRun(weekStart)
|
||||||
// Makes the week's Supply run again after a push could not make it.
|
// Makes the week's Supply run again after a push could not make it.
|
||||||
const generate = useGenerateSupplyRun(weekStart)
|
const generate = useGenerateSupplyRun(weekStart)
|
||||||
@@ -431,25 +429,18 @@ function WeekBoard({
|
|||||||
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
|
||||||
)
|
)
|
||||||
|
|
||||||
// swapIn sends the swap chosen in the sheet and closes it.
|
// swapIn sends the swap chosen in the sheet, dropped on a day or retried
|
||||||
|
// (useWeekSwap) and closes the sheet. The Supply run made again on the
|
||||||
|
// Plan after a push is left behind with the push.
|
||||||
function swapIn(chosen: Swap) {
|
function swapIn(chosen: Swap) {
|
||||||
setSwappingDate(null)
|
setSwappingDate(null)
|
||||||
push.reset()
|
|
||||||
generate.reset()
|
generate.reset()
|
||||||
swap.mutate(chosen)
|
sendSwap(chosen)
|
||||||
}
|
}
|
||||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
// openSwap opens the swap sheet on a day, unless a swap is refused now
|
||||||
// nor dropped, while a swap of the week is pending.
|
// (useWeekSwap). A drag is not picked up, nor dropped, then either.
|
||||||
function dropIn(chosen: Swap) {
|
|
||||||
swap.dismiss()
|
|
||||||
swapIn(chosen)
|
|
||||||
}
|
|
||||||
// openSwap opens the swap sheet on a day. One swap of the week is sent at
|
|
||||||
// a time, even across leaving the week and coming back: the plan the
|
|
||||||
// server answers it with must not overwrite another still pending. Nor is
|
|
||||||
// one sent while the week is being pushed to Calendar.
|
|
||||||
function openSwap(date: string) {
|
function openSwap(date: string) {
|
||||||
if (swap.pending || pushing) return
|
if (refused) return
|
||||||
swap.dismiss()
|
swap.dismiss()
|
||||||
setSwappingDate(date)
|
setSwappingDate(date)
|
||||||
}
|
}
|
||||||
@@ -488,7 +479,7 @@ function WeekBoard({
|
|||||||
body =
|
body =
|
||||||
layout !== 'mobile' ? (
|
layout !== 'mobile' ? (
|
||||||
<>
|
<>
|
||||||
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
|
<DropBoard days={days} pending={refused} drop={swapIn} />
|
||||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<DayColumn
|
<DayColumn
|
||||||
|
|||||||
@@ -532,6 +532,37 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WeekSwap is the one way a day of a week is swapped, wherever the swap is
|
||||||
|
// chosen: the week's swaps and pushes, whether a swap is refused now, and
|
||||||
|
// swapIn to send one.
|
||||||
|
export type WeekSwap = {
|
||||||
|
swap: SwapDay
|
||||||
|
push: PushToCalendar
|
||||||
|
refused: boolean
|
||||||
|
swapIn: (chosen: Swap) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// useWeekSwap swaps a day of the week starting on weekStart, from the Draft
|
||||||
|
// board or a dinner's Add to this week. One swap of the week is sent at a
|
||||||
|
// time, even across leaving the week and coming back: the plan the server
|
||||||
|
// answers it with must not overwrite another still pending. Nor is one sent
|
||||||
|
// while the week is being pushed to Calendar: refused says so, and swapIn
|
||||||
|
// then throws. A swap leaves the week's settled swaps behind, and its
|
||||||
|
// pushes: what a push said of Calendar and the Supply run was said of the
|
||||||
|
// plan before the swap, not the one swapped.
|
||||||
|
export function useWeekSwap(weekStart: string): WeekSwap {
|
||||||
|
const swap = useSwapDay(weekStart)
|
||||||
|
const push = usePushToCalendar(weekStart)
|
||||||
|
const refused = swap.pending || push.pending
|
||||||
|
function swapIn(chosen: Swap) {
|
||||||
|
if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`)
|
||||||
|
swap.dismiss()
|
||||||
|
push.reset()
|
||||||
|
swap.mutate(chosen)
|
||||||
|
}
|
||||||
|
return { swap, push, refused, swapIn }
|
||||||
|
}
|
||||||
|
|
||||||
// settleUnmadePushes is called as the Supply run of the week starting on
|
// settleUnmadePushes is called as the Supply run of the week starting on
|
||||||
// weekStart starts being made, from the Plan or the Supply run screen
|
// weekStart starts being made, from the Plan or the Supply run screen
|
||||||
// (useGenerateSupplyRun). The function it answers with is called once the
|
// (useGenerateSupplyRun). The function it answers with is called once the
|
||||||
|
|||||||
Reference in New Issue
Block a user