/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import React, {
useRef,
useState,
useEffect,
useCallback,
useMemo,
} from "react";
import { useSelector, batch } from "react-redux";
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import { useIntersectionObserver, useConfetti } from "../../../lib/utils";
const TASK_TYPE = {
IN_PROGRESS: "tasks",
COMPLETED: "completed",
};
const USER_ACTION_TYPES = {
LIST_COPY: "list_copy",
LIST_CREATE: "list_create",
LIST_EDIT: "list_edit",
LIST_DELETE: "list_delete",
TASK_CREATE: "task_create",
TASK_EDIT: "task_edit",
TASK_DELETE: "task_delete",
TASK_COMPLETE: "task_complete",
};
const PREF_WIDGETS_LISTS_MAX_LISTS = "widgets.lists.maxLists";
const PREF_WIDGETS_LISTS_MAX_LISTITEMS = "widgets.lists.maxListItems";
const PREF_WIDGETS_LISTS_BADGE_ENABLED = "widgets.lists.badge.enabled";
const PREF_WIDGETS_LISTS_BADGE_LABEL = "widgets.lists.badge.label";
const PREF_NOVA_ENABLED = "nova.enabled";
// eslint-disable-next-line max-statements
function Lists({
dispatch,
handleUserInteraction,
isMaximized,
widgetsMayBeMaximized,
}) {
const prefs = useSelector(state => state.Prefs.values);
const { selected, lists } = useSelector(state => state.ListsWidget);
const [newTask, setNewTask] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [pendingNewList, setPendingNewList] = useState(null);
const selectedList = useMemo(() => lists[selected], [lists, selected]);
// Bug 2012829 - Calculate widget size dynamically based on isMaximized prop.
// Future sizes: mini, medium, large.
const widgetSize = isMaximized ? "medium" : "small";
const prevCompletedCount = useRef(selectedList?.completed?.length || 0);
const inputRef = useRef(null);
const selectRef = useRef(null);
const reorderListRef = useRef(null);
const [canvasRef, fireConfetti] = useConfetti();
const impressionFired = useRef(false);
const handleListInteraction = useCallback(
() => handleUserInteraction("lists"),
[handleUserInteraction]
);
// store selectedList with useMemo so it isnt re-calculated on every re-render
const isValidUrl = useCallback(str => URL.canParse(str), []);
const handleIntersection = useCallback(() => {
if (impressionFired.current) {
return;
}
impressionFired.current = true;
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_USER_IMPRESSION,
})
);
const telemetryData = {
widget_name: "lists",
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_IMPRESSION,
data: telemetryData,
})
);
});
}, [dispatch, widgetsMayBeMaximized, widgetSize]);
const listsRef = useIntersectionObserver(handleIntersection);
const reorderLists = useCallback(
(draggedElement, targetElement, before = false) => {
const draggedIndex = selectedList.tasks.findIndex(
({ id }) => id === draggedElement.id
);
const targetIndex = selectedList.tasks.findIndex(
({ id }) => id === targetElement.id
);
// return early is index is not found
if (
draggedIndex === -1 ||
targetIndex === -1 ||
draggedIndex === targetIndex
) {
return;
}
const reordered = [...selectedList.tasks];
const [removed] = reordered.splice(draggedIndex, 1);
const insertIndex = before ? targetIndex : targetIndex + 1;
reordered.splice(
insertIndex > draggedIndex ? insertIndex - 1 : insertIndex,
0,
removed
);
const updatedLists = {
...lists,
[selected]: {
...selectedList,
tasks: reordered,
},
};
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
handleListInteraction();
},
[lists, selected, selectedList, dispatch, handleListInteraction]
);
const moveTask = useCallback(
(task, direction) => {
const index = selectedList.tasks.findIndex(({ id }) => id === task.id);
// guardrail a falsey index
if (index === -1) {
return;
}
const targetIndex = direction === "up" ? index - 1 : index + 1;
const before = direction === "up";
const targetTask = selectedList.tasks[targetIndex];
if (targetTask) {
reorderLists(task, targetTask, before);
}
},
[selectedList, reorderLists]
);
useEffect(() => {
const selectNode = selectRef.current;
const reorderNode = reorderListRef.current;
if (!selectNode || !reorderNode) {
return undefined;
}
function handleSelectChange(e) {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_CHANGE_SELECTED,
data: e.target.value,
})
);
handleListInteraction();
}
function handleReorder(e) {
const { draggedElement, targetElement, position } = e.detail;
reorderLists(draggedElement, targetElement, position === -1);
}
reorderNode.addEventListener("reorder", handleReorder);
selectNode.addEventListener("change", handleSelectChange);
return () => {
selectNode.removeEventListener("change", handleSelectChange);
reorderNode.removeEventListener("reorder", handleReorder);
};
}, [dispatch, isEditing, reorderLists, handleListInteraction]);
// effect that enables editing new list name only after store has been hydrated
useEffect(() => {
if (selected === pendingNewList) {
setIsEditing(true);
setPendingNewList(null);
}
}, [selected, pendingNewList]);
function saveTask() {
const trimmedTask = newTask.trimEnd();
// only add new task if it has a length, to avoid creating empty tasks
if (trimmedTask) {
const formattedTask = {
value: trimmedTask,
completed: false,
created: Date.now(),
id: crypto.randomUUID(),
isUrl: isValidUrl(trimmedTask),
};
const updatedLists = {
...lists,
[selected]: {
...selectedList,
tasks: [formattedTask, ...lists[selected].tasks],
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_LISTS_USER_EVENT,
data: { userAction: USER_ACTION_TYPES.TASK_CREATE },
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "widget",
user_action: USER_ACTION_TYPES.TASK_CREATE,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
setNewTask("");
handleListInteraction();
}
}
function updateTask(updatedTask, type) {
const isCompletedType = type === TASK_TYPE.COMPLETED;
const isNowCompleted = updatedTask.completed;
let newTasks = selectedList.tasks;
let newCompleted = selectedList.completed;
let userAction;
// If the task is in the completed array and is now unchecked
const shouldMoveToTasks = isCompletedType && !isNowCompleted;
// If we're moving the task from tasks → completed (user checked it)
const shouldMoveToCompleted = !isCompletedType && isNowCompleted;
// Move task from completed -> task
if (shouldMoveToTasks) {
newCompleted = selectedList.completed.filter(
task => task.id !== updatedTask.id
);
newTasks = [...selectedList.tasks, updatedTask];
// Move task to completed, but also create local version
} else if (shouldMoveToCompleted) {
newTasks = selectedList.tasks.filter(task => task.id !== updatedTask.id);
newCompleted = [...selectedList.completed, updatedTask];
userAction = USER_ACTION_TYPES.TASK_COMPLETE;
} else {
const targetKey = isCompletedType ? "completed" : "tasks";
const updatedArray = selectedList[targetKey].map(task =>
task.id === updatedTask.id ? updatedTask : task
);
// In-place update: toggle checkbox (but stay in same array or edit name)
if (targetKey === "tasks") {
newTasks = updatedArray;
} else {
newCompleted = updatedArray;
}
userAction = USER_ACTION_TYPES.TASK_EDIT;
}
const updatedLists = {
...lists,
[selected]: {
...selectedList,
tasks: newTasks,
completed: newCompleted,
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
if (userAction) {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_USER_EVENT,
data: { userAction },
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "widget",
user_action: userAction,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
}
});
handleListInteraction();
}
function deleteTask(task, type) {
const selectedTasks = lists[selected][type];
const updatedTasks = selectedTasks.filter(({ id }) => id !== task.id);
const updatedLists = {
...lists,
[selected]: {
...selectedList,
[type]: updatedTasks,
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_LISTS_USER_EVENT,
data: { userAction: USER_ACTION_TYPES.TASK_DELETE },
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "widget",
user_action: USER_ACTION_TYPES.TASK_DELETE,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
handleListInteraction();
}
function handleKeyDown(e) {
if (e.key === "Enter" && document.activeElement === inputRef.current) {
saveTask();
} else if (
e.key === "Escape" &&
document.activeElement === inputRef.current
) {
// Clear out the input when esc is pressed
setNewTask("");
}
}
function handleListNameSave(newLabel) {
const trimmedLabel = newLabel.trimEnd();
if (trimmedLabel && trimmedLabel !== selectedList?.label) {
const updatedLists = {
...lists,
[selected]: {
...selectedList,
label: trimmedLabel,
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_LISTS_USER_EVENT,
data: { userAction: USER_ACTION_TYPES.LIST_EDIT },
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "widget",
user_action: USER_ACTION_TYPES.LIST_EDIT,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
setIsEditing(false);
handleListInteraction();
}
}
function handleCreateNewList() {
const id = crypto.randomUUID();
const newLists = {
...lists,
[id]: {
label: "",
tasks: [],
completed: [],
},
};
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: newLists },
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_CHANGE_SELECTED,
data: id,
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_LISTS_USER_EVENT,
data: { userAction: USER_ACTION_TYPES.LIST_CREATE },
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "widget",
user_action: USER_ACTION_TYPES.LIST_CREATE,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
setPendingNewList(id);
handleListInteraction();
}
function handleCancelNewList() {
// If current list is new and has no label/tasks, remove it
if (!selectedList?.label && selectedList?.tasks?.length === 0) {
const updatedLists = { ...lists };
delete updatedLists[selected];
const listKeys = Object.keys(updatedLists);
const key = listKeys[listKeys.length - 1];
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_CHANGE_SELECTED,
data: key,
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_LISTS_USER_EVENT,
data: { userAction: USER_ACTION_TYPES.LIST_DELETE },
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "widget",
user_action: USER_ACTION_TYPES.LIST_DELETE,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
}
handleListInteraction();
}
function handleDeleteList() {
let updatedLists = { ...lists };
if (updatedLists[selected]) {
delete updatedLists[selected];
// if this list was the last one created, add a new list as default
if (Object.keys(updatedLists)?.length === 0) {
updatedLists = {
[crypto.randomUUID()]: {
label: "",
tasks: [],
completed: [],
},
};
}
const listKeys = Object.keys(updatedLists);
const key = listKeys[listKeys.length - 1];
batch(() => {
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_UPDATE,
data: { lists: updatedLists },
})
);
dispatch(
ac.AlsoToMain({
type: at.WIDGETS_LISTS_CHANGE_SELECTED,
data: key,
})
);
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_LISTS_USER_EVENT,
data: { userAction: USER_ACTION_TYPES.LIST_DELETE },
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "widget",
user_action: USER_ACTION_TYPES.LIST_DELETE,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
}
handleListInteraction();
}
function handleHideLists() {
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.SET_PREF,
data: {
name: "widgets.lists.enabled",
value: false,
},
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "context_menu",
enabled: false,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_ENABLED,
data: telemetryData,
})
);
});
handleListInteraction();
}
function handleCopyListToClipboard() {
const currentList = lists[selected];
if (!currentList) {
return;
}
const { label, tasks = [], completed = [] } = currentList;
const uncompleted = tasks.filter(task => !task.completed);
const currentCompleted = tasks.filter(task => task.completed);
// In order in include all items, we need to iterate through both current and completed tasks list and mark format all completed tasks accordingly.
const formatted = [
`List: ${label}`,
`---`,
...uncompleted.map(task => `- [ ] ${task.value}`),
...currentCompleted.map(task => `- [x] ${task.value}`),
...completed.map(task => `- [x] ${task.value}`),
].join("\n");
try {
navigator.clipboard.writeText(formatted);
} catch (err) {
console.error("Copy failed", err);
}
batch(() => {
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_LISTS_USER_EVENT,
data: { userAction: USER_ACTION_TYPES.LIST_COPY },
})
);
const telemetryData = {
widget_name: "lists",
widget_source: "widget",
user_action: USER_ACTION_TYPES.LIST_COPY,
widget_size: widgetsMayBeMaximized ? widgetSize : "medium",
};
dispatch(
ac.OnlyToMain({
type: at.WIDGETS_USER_EVENT,
data: telemetryData,
})
);
});
handleListInteraction();
}
function handleLearnMore() {
dispatch(
ac.OnlyToMain({
type: at.OPEN_LINK,
data: {
url: "https://support.mozilla.org/kb/firefox-new-tab-widgets",
},
})
);
handleListInteraction();
}
// Reset baseline only when switching lists
useEffect(() => {
prevCompletedCount.current = selectedList?.completed?.length || 0;
// intentionally leaving out selectedList from dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected]);
useEffect(() => {
if (selectedList) {
const doneCount = selectedList.completed?.length || 0;
const previous = Math.floor(prevCompletedCount.current / 5);
const current = Math.floor(doneCount / 5);
if (current > previous) {
fireConfetti();
}
prevCompletedCount.current = doneCount;
}
}, [selectedList, fireConfetti, selected]);
if (!lists) {
return null;
}
// Enforce maximum count limits to lists
const currentListsCount = Object.keys(lists).length;
// Ensure a minimum of 1, but allow higher values from prefs
const maxListsCount = Math.max(1, prefs[PREF_WIDGETS_LISTS_MAX_LISTS]);
const isAtMaxListsLimit = currentListsCount >= maxListsCount;
// Enforce maximum count limits to list items
// The maximum applies to the total number of items (both incomplete and completed items)
const currentSelectedListItemsCount =
selectedList?.tasks.length + selectedList?.completed.length;
// Ensure a minimum of 1, but allow higher values from prefs
const maxListItemsCount = Math.max(
1,
prefs[PREF_WIDGETS_LISTS_MAX_LISTITEMS]
);
const isAtMaxListItemsLimit =
currentSelectedListItemsCount >= maxListItemsCount;
// Figure out if the selected list is the first (default) or a new one.
// Index 0 → use "Task list"; any later index → use "New list".
// Fallback to 0 if the selected id isn’t found.
const listKeys = Object.keys(lists);
const selectedIndex = Math.max(0, listKeys.indexOf(selected));
const listNamePlaceholder =
currentListsCount > 1 && selectedIndex !== 0
? "newtab-widget-lists-name-placeholder-new"
: "newtab-widget-lists-name-placeholder-default";
const nimbusBadgeEnabled = prefs.widgetsConfig?.listsBadgeEnabled;
const nimbusBadgeLabel = prefs.widgetsConfig?.listsBadgeLabel;
const nimbusBadgeTrainhopEnabled =
prefs.trainhopConfig?.widgets?.listsBadgeEnabled;
const nimbusBadgeTrainhopLabel =
prefs.trainhopConfig?.widgets?.listsBadgeLabel;
const badgeEnabled =
(nimbusBadgeEnabled || nimbusBadgeTrainhopEnabled) ??
prefs[PREF_WIDGETS_LISTS_BADGE_ENABLED] ??
false;
const badgeLabel =
(nimbusBadgeLabel || nimbusBadgeTrainhopLabel) ??
prefs[PREF_WIDGETS_LISTS_BADGE_LABEL] ??
"";
// @nova-cleanup(remove-pref): Remove pref check, always apply col-4 class after Nova ships
const novaEnabled = prefs[PREF_NOVA_ENABLED];
return (