ink-framework-files 0.1.0-alpha.10 → 0.1.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ink-native.json +4 -0
- package/package.json +7 -5
- package/src/library.ts +133 -0
- package/src/media-grid.ts +74 -0
- package/src/media.ts +18 -31
package/ink-native.json
CHANGED
package/package.json
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-framework-files",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.13",
|
|
4
4
|
"exports": {
|
|
5
5
|
".": "./src/index.ts",
|
|
6
6
|
"./images": "./src/images.ts",
|
|
7
|
-
"./media": "./src/media.ts"
|
|
7
|
+
"./media": "./src/media.ts",
|
|
8
|
+
"./library": "./src/library.ts"
|
|
8
9
|
},
|
|
9
10
|
"peerDependencies": {
|
|
10
|
-
"ink": "npm:ink-framework@0.1.0-alpha.
|
|
11
|
+
"ink": "npm:ink-framework@0.1.0-alpha.13",
|
|
11
12
|
"react": "19.2.8"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
|
-
"@ink/lightos": "npm:ink-framework-lightos@0.1.0-alpha.
|
|
15
|
+
"@ink/lightos": "npm:ink-framework-lightos@0.1.0-alpha.13"
|
|
15
16
|
},
|
|
16
17
|
"license": "MIT",
|
|
17
18
|
"repository": {
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"files": [
|
|
23
24
|
"src",
|
|
24
25
|
"*.json",
|
|
25
|
-
"*.ts"
|
|
26
|
+
"*.ts",
|
|
27
|
+
"*.md"
|
|
26
28
|
]
|
|
27
29
|
}
|
package/src/library.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createElement, type ReactNode } from "ink/internal/react";
|
|
2
|
+
import { actionBarProps, type ActionBarItem, type IconAsset } from "ink";
|
|
3
|
+
import { callNative, NativeError, onNativeMessage } from "ink/native";
|
|
4
|
+
import { MediaGridView } from "./media-grid";
|
|
5
|
+
|
|
6
|
+
export type MediaKind = "image" | "video" | "all";
|
|
7
|
+
|
|
8
|
+
export type MediaItem = {
|
|
9
|
+
id: string;
|
|
10
|
+
src: string;
|
|
11
|
+
name: string;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
/** Seconds since the epoch when the item was added to the library. */
|
|
16
|
+
added: number;
|
|
17
|
+
duration?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MediaPage = { items: MediaItem[]; nextCursor: string | null; version: string };
|
|
21
|
+
export type MediaCounts = { all: number; image: number; video: number; groups: Record<string, number> };
|
|
22
|
+
|
|
23
|
+
function decodeItem(item: unknown): MediaItem {
|
|
24
|
+
if (typeof item !== "object" || item === null || !["id", "src", "name", "mimeType"].every(key => typeof Reflect.get(item, key) === "string")
|
|
25
|
+
|| !["width", "height", "added"].every(key => typeof Reflect.get(item, key) === "number")) throw new NativeError("protocol", "Invalid media item");
|
|
26
|
+
return item as MediaItem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const mediaLibrary = {
|
|
30
|
+
/** Newest additions first. Pass the previous page's `nextCursor` to continue. */
|
|
31
|
+
async list(options: { kind?: MediaKind; ids?: readonly string[]; cursor?: string | null; limit?: number; signal?: AbortSignal } = {}): Promise<MediaPage> {
|
|
32
|
+
const page: unknown = JSON.parse(await callNative("files", "media-list", {
|
|
33
|
+
kind: options.kind ?? "all", ids: options.ids, cursor: options.cursor ?? undefined, limit: options.limit ?? 60, order: "added", previews: true,
|
|
34
|
+
}, { signal: options.signal }));
|
|
35
|
+
if (typeof page !== "object" || page === null || !("items" in page) || !Array.isArray(page.items) || !("nextCursor" in page) || !("version" in page)
|
|
36
|
+
|| (page.nextCursor !== null && typeof page.nextCursor !== "string") || typeof page.version !== "string") throw new NativeError("protocol", "Invalid media page");
|
|
37
|
+
return { items: page.items.map(decodeItem), nextCursor: page.nextCursor, version: page.version };
|
|
38
|
+
},
|
|
39
|
+
/** Counts the whole library and optional groups of IDs without loading their media items. */
|
|
40
|
+
async counts(options: { groups?: Readonly<Record<string, readonly string[]>>; signal?: AbortSignal } = {}): Promise<MediaCounts> {
|
|
41
|
+
const counts: unknown = JSON.parse(await callNative("files", "media-counts", { groups: options.groups ?? {} }, { signal: options.signal }));
|
|
42
|
+
const count = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
43
|
+
if (typeof counts !== "object" || counts === null || !("all" in counts) || !count(counts.all)
|
|
44
|
+
|| !("image" in counts) || !count(counts.image) || !("video" in counts) || !count(counts.video)
|
|
45
|
+
|| !("groups" in counts) || typeof counts.groups !== "object" || counts.groups === null || Array.isArray(counts.groups)
|
|
46
|
+
|| !Object.values(counts.groups).every(count)) throw new NativeError("protocol", "Invalid media counts");
|
|
47
|
+
return counts as MediaCounts;
|
|
48
|
+
},
|
|
49
|
+
/** Deletes items from the device. Resolves with the deleted IDs, or null when declined. */
|
|
50
|
+
async remove(ids: readonly string[]): Promise<string[] | null> {
|
|
51
|
+
const result: unknown = JSON.parse(await callNative("files", "media-delete", { ids }, { timeoutMs: 600_000 }));
|
|
52
|
+
if (result === null) return null;
|
|
53
|
+
if (!Array.isArray(result) || !result.every(id => typeof id === "string")) throw new NativeError("protocol", "Invalid deleted media IDs");
|
|
54
|
+
return result;
|
|
55
|
+
},
|
|
56
|
+
/** Rotates a photo clockwise. Resolves with the updated item, or null when declined. */
|
|
57
|
+
async rotate(id: string, degrees: 90 | 180 | 270): Promise<MediaItem | null> {
|
|
58
|
+
const result: unknown = JSON.parse(await callNative("files", "media-rotate", { id, degrees }, { timeoutMs: 600_000 }));
|
|
59
|
+
return result === null ? null : decodeItem(result);
|
|
60
|
+
},
|
|
61
|
+
/** Prepares instant grid previews for the supplied library items in the background. */
|
|
62
|
+
async preparePreviews(options: { ids: readonly string[]; signal?: AbortSignal }): Promise<number> {
|
|
63
|
+
return Number(await callNative("files", "media-previews", { ids: options.ids }, { signal: options.signal }));
|
|
64
|
+
},
|
|
65
|
+
/** Calls `listener` after photos or videos are added, changed or removed, including while the app was hidden. */
|
|
66
|
+
watch(listener: (version: string) => void, onError: (error: Error) => void): () => void {
|
|
67
|
+
watchers.set(listener, onError);
|
|
68
|
+
const request = watchRequest ??= callNative("files", "media-watch", {});
|
|
69
|
+
void request.catch(error => {
|
|
70
|
+
if (watchRequest === request) watchRequest = null;
|
|
71
|
+
if (watchers.get(listener) === onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
72
|
+
});
|
|
73
|
+
return () => {
|
|
74
|
+
watchers.delete(listener);
|
|
75
|
+
if (!watchers.size) {
|
|
76
|
+
watchRequest = null;
|
|
77
|
+
void callNative("files", "media-unwatch", {}).catch(error => onError(error instanceof Error ? error : new Error(String(error))));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const watchers = new Map<(version: string) => void, (error: Error) => void>();
|
|
84
|
+
let watchRequest: Promise<string> | null = null;
|
|
85
|
+
onNativeMessage("media-changed", message => {
|
|
86
|
+
if (typeof message.data !== "object" || message.data === null || !("version" in message.data) || typeof message.data.version !== "string") {
|
|
87
|
+
throw new NativeError("protocol", "Invalid media library change");
|
|
88
|
+
}
|
|
89
|
+
for (const listener of [...watchers.keys()]) listener(message.data.version);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** Full-resolution source for a photo, decoded at the size it is displayed. */
|
|
93
|
+
export function fullSource(item: MediaItem) {
|
|
94
|
+
return item.src + "&full=1";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A `content://` URI for video playback. */
|
|
98
|
+
export function contentUri(item: MediaItem) {
|
|
99
|
+
const [kind, id] = item.id.split(":");
|
|
100
|
+
return `content://media/external/${kind === "video" ? "video" : "images"}/media/${id}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type MediaGridProps = {
|
|
104
|
+
items: readonly MediaItem[];
|
|
105
|
+
/** A selection dims the other items. Omit it or pass null to browse normally. */
|
|
106
|
+
selected?: ReadonlySet<string> | null;
|
|
107
|
+
onPress: (item: MediaItem, index: number) => void;
|
|
108
|
+
onLongPress?: (item: MediaItem, index: number) => void;
|
|
109
|
+
hasMore?: boolean;
|
|
110
|
+
onLoadMore?: () => Promise<void>;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Three-column, edge-to-edge grid of library items. Place it in `MediaGridScreen`. */
|
|
114
|
+
export function MediaGrid(props: MediaGridProps) {
|
|
115
|
+
return createElement(MediaGridView<MediaItem>, {
|
|
116
|
+
...props, selectionStyle: "dimmed", hideIncompleteRow: true,
|
|
117
|
+
onLongPress: (item, index) => props.onLongPress?.(item, index),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A screen without margins for `MediaGrid`, with an optional bottom action bar. */
|
|
122
|
+
export function MediaGridScreen({ title, onTitlePress, titleIcon, rightAction, actionBar, children }: {
|
|
123
|
+
title?: string;
|
|
124
|
+
onTitlePress?: () => void;
|
|
125
|
+
titleIcon?: IconAsset;
|
|
126
|
+
rightAction?: { icon: IconAsset; onPress: () => void };
|
|
127
|
+
actionBar?: readonly ActionBarItem[];
|
|
128
|
+
children?: ReactNode;
|
|
129
|
+
}) {
|
|
130
|
+
return createElement("MediaPickerScreen", {
|
|
131
|
+
title, onTitlePress, titleIcon, scrollBar: "auto", rightIcon: rightAction?.icon, onRightPress: rightAction?.onPress, ...actionBarProps(actionBar),
|
|
132
|
+
}, children);
|
|
133
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createElement, useLayoutEffect, useMemo, useRef } from "ink/internal/react";
|
|
2
|
+
import { List } from "ink";
|
|
3
|
+
import { fixedListRow } from "ink/internal/list";
|
|
4
|
+
import { checkCircleFilled, videoFileFilled } from "ink/icons";
|
|
5
|
+
|
|
6
|
+
type MediaGridItem = { id: string; src: string; mimeType: string };
|
|
7
|
+
type MediaGridViewProps<T> = {
|
|
8
|
+
items: readonly T[];
|
|
9
|
+
selected?: ReadonlySet<string> | null;
|
|
10
|
+
selectionStyle: "selected" | "dimmed";
|
|
11
|
+
onPress: (item: T, index: number) => void;
|
|
12
|
+
onLongPress?: (item: T, index: number) => void;
|
|
13
|
+
hideIncompleteRow?: boolean;
|
|
14
|
+
hasMore?: boolean;
|
|
15
|
+
onLoadMore?: () => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const mediaRow = /* @__PURE__ */ fixedListRow<ReturnType<typeof gridCell>[]>((node, field) => node("MediaGridRow", {},
|
|
19
|
+
Array.from({ length: 3 }, (_, index) => node("MediaCell", {
|
|
20
|
+
key: field(cells => cells[index].key),
|
|
21
|
+
hidden: field(cells => cells[index].hidden), src: field(cells => cells[index].src),
|
|
22
|
+
selected: field(cells => cells[index].selected), dimmed: field(cells => cells[index].dimmed),
|
|
23
|
+
video: field(cells => cells[index].video), checkIcon: checkCircleFilled, videoIcon: videoFileFilled,
|
|
24
|
+
onPress: field(cells => cells[index].onPress), onLongPress: field(cells => cells[index].onLongPress),
|
|
25
|
+
}))));
|
|
26
|
+
|
|
27
|
+
type GridCell<T> = { item: T; index: number; selected: boolean; dimmed: boolean };
|
|
28
|
+
type GridRow<T> = { key: string; cells: GridCell<T>[]; longPress: boolean };
|
|
29
|
+
type GridHandlers<T> = Pick<MediaGridViewProps<T>, "onPress" | "onLongPress">;
|
|
30
|
+
|
|
31
|
+
function gridCell<T extends MediaGridItem>(handlers: { current: GridHandlers<T> }, row: GridRow<T>, index: number) {
|
|
32
|
+
const entry = row.cells[index];
|
|
33
|
+
return {
|
|
34
|
+
key: entry?.item.id,
|
|
35
|
+
hidden: !entry, src: (entry ?? row.cells[0]).item.src, selected: !!entry?.selected, dimmed: !!entry?.dimmed,
|
|
36
|
+
video: !!entry?.item.mimeType.startsWith("video/"),
|
|
37
|
+
// Handlers are read when pressed, so unchanged rows never hold stale callbacks.
|
|
38
|
+
onPress: entry && (() => handlers.current.onPress(entry.item, entry.index)),
|
|
39
|
+
onLongPress: entry && row.longPress ? () => handlers.current.onLongPress?.(entry.item, entry.index) : undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function MediaGridView<T extends MediaGridItem>({ items, selected, selectionStyle, onPress, onLongPress, hideIncompleteRow, hasMore, onLoadMore }: MediaGridViewProps<T>) {
|
|
44
|
+
const handlers = useRef<GridHandlers<T>>({ onPress, onLongPress });
|
|
45
|
+
useLayoutEffect(() => { handlers.current = { onPress, onLongPress }; });
|
|
46
|
+
const cache = useRef(new Map<string, GridRow<T>>());
|
|
47
|
+
const longPress = !!onLongPress;
|
|
48
|
+
const rows = useMemo(() => {
|
|
49
|
+
const next = new Map<string, GridRow<T>>();
|
|
50
|
+
const rows: GridRow<T>[] = [];
|
|
51
|
+
const count = hideIncompleteRow && hasMore ? items.length - items.length % 3 : items.length;
|
|
52
|
+
for (let index = 0; index < count; index += 3) {
|
|
53
|
+
const cells = items.slice(index, index + 3).map((item, offset) => ({
|
|
54
|
+
item, index: index + offset,
|
|
55
|
+
selected: selectionStyle === "selected" && !!selected?.has(item.id),
|
|
56
|
+
dimmed: selectionStyle === "dimmed" && selected != null && !selected.has(item.id),
|
|
57
|
+
}));
|
|
58
|
+
const key = cells[0].item.id;
|
|
59
|
+
const previous = cache.current.get(key);
|
|
60
|
+
// Rows keep their identity unless an item, its position or its presentation changes.
|
|
61
|
+
const row = previous && previous.longPress === longPress && previous.cells.length === cells.length && previous.cells.every((cell, offset) =>
|
|
62
|
+
cell.item === cells[offset].item && cell.index === cells[offset].index && cell.selected === cells[offset].selected && cell.dimmed === cells[offset].dimmed)
|
|
63
|
+
? previous : { key, cells, longPress };
|
|
64
|
+
next.set(key, row);
|
|
65
|
+
rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
cache.current = next;
|
|
68
|
+
return rows;
|
|
69
|
+
}, [items, selected, selectionStyle, hideIncompleteRow, hasMore, longPress]);
|
|
70
|
+
return createElement(List<GridRow<T>>, {
|
|
71
|
+
items: rows, keyExtractor: row => row.key, gap: 0, hasMore, onLoadMore,
|
|
72
|
+
renderItem: mediaRow((row: GridRow<T>) => Array.from({ length: 3 }, (_, index) => gridCell(handlers, row, index))),
|
|
73
|
+
});
|
|
74
|
+
}
|
package/src/media.ts
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
import { lightos, type PermissionStatus } from "@ink/lightos";
|
|
2
|
-
import { createElement, useCallback, useEffect,
|
|
3
|
-
import { EmptyState, ErrorState,
|
|
4
|
-
import { nativeListRow, nativeListTemplate } from "ink/internal/list";
|
|
2
|
+
import { createElement, useCallback, useEffect, useRef, useState } from "ink/internal/react";
|
|
3
|
+
import { EmptyState, ErrorState, LoadingState, Screen, Text } from "ink";
|
|
5
4
|
import { callNative, NativeError } from "ink/native";
|
|
6
5
|
import { files, type FileRef } from "./index";
|
|
7
6
|
import { decodeFileRef } from "./decode";
|
|
8
|
-
import { check
|
|
9
|
-
|
|
10
|
-
const mediaTemplate = nativeListTemplate((node, field) => node("MediaGridRow", {},
|
|
11
|
-
Array.from({ length: 3 }, (_, index) => node("MediaCell", {
|
|
12
|
-
hidden: field(index, "hidden"), src: field(index, "src"), selected: field(index, "selected"),
|
|
13
|
-
video: field(index, "video"), checkIcon: checkCircleFilled, videoIcon: videoFileFilled,
|
|
14
|
-
onPress: field(index, "onPress"),
|
|
15
|
-
}))));
|
|
7
|
+
import { check } from "ink/icons";
|
|
8
|
+
import { MediaGridView } from "./media-grid";
|
|
16
9
|
|
|
17
10
|
type MediaItem = { id: string; src: string; name: string; mimeType: string; width: number; height: number };
|
|
18
11
|
type MediaPage = { items: MediaItem[]; nextCursor: string | null };
|
|
19
12
|
export type MediaPickerProps = {
|
|
20
13
|
kind?: "image" | "video" | "all";
|
|
21
14
|
title?: string;
|
|
15
|
+
selectionOrder?: "library" | "selection";
|
|
16
|
+
multiple?: boolean;
|
|
22
17
|
onSelect: (files: FileRef[]) => void | Promise<void>;
|
|
23
18
|
};
|
|
24
19
|
|
|
@@ -33,9 +28,14 @@ function decodePage(value: string): MediaPage {
|
|
|
33
28
|
return page as MediaPage;
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
export function MediaPicker({ kind = "all", title = kind === "image" ? "Photos" : kind === "video" ? "Videos" : "Photos and videos", onSelect }: MediaPickerProps) {
|
|
31
|
+
export function MediaPicker({ kind = "all", title = kind === "image" ? "Photos" : kind === "video" ? "Videos" : "Photos and videos", selectionOrder = "library", multiple = true, onSelect }: MediaPickerProps) {
|
|
37
32
|
const [items, setItems] = useState<MediaItem[]>([]);
|
|
38
33
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
34
|
+
const toggleSelected = useCallback((id: string) => setSelected(previous => {
|
|
35
|
+
const next = new Set(multiple ? previous : []);
|
|
36
|
+
if (previous.has(id)) next.delete(id); else next.add(id);
|
|
37
|
+
return next;
|
|
38
|
+
}), [multiple]);
|
|
39
39
|
const [permission, setPermission] = useState<PermissionStatus | null>(null);
|
|
40
40
|
const [loading, setLoading] = useState(true);
|
|
41
41
|
const [error, setError] = useState<Error | null>(null);
|
|
@@ -102,8 +102,9 @@ export function MediaPicker({ kind = "all", title = kind === "image" ? "Photos"
|
|
|
102
102
|
const imported: FileRef[] = [];
|
|
103
103
|
let delivered = false;
|
|
104
104
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
const orderedIds = selectionOrder === "selection" ? [...selected] : items.filter(item => selected.has(item.id)).map(item => item.id);
|
|
106
|
+
for (const id of orderedIds) {
|
|
107
|
+
const file = decodeFileRef(await callNative("files", "media-import", { id }, { signal, timeoutMs: 600_000 }));
|
|
107
108
|
if (!file) throw new NativeError("protocol", "Media import returned no file");
|
|
108
109
|
imported.push(file);
|
|
109
110
|
}
|
|
@@ -121,11 +122,6 @@ export function MediaPicker({ kind = "all", title = kind === "image" ? "Photos"
|
|
|
121
122
|
}
|
|
122
123
|
};
|
|
123
124
|
|
|
124
|
-
const rows = useMemo(() => {
|
|
125
|
-
const rows: MediaItem[][] = [];
|
|
126
|
-
for (let index = 0; index < items.length; index += 3) rows.push(items.slice(index, index + 3));
|
|
127
|
-
return rows;
|
|
128
|
-
}, [items]);
|
|
129
125
|
if (loading) return createElement(Screen, { title }, createElement(LoadingState, { label: "Loading library…" }));
|
|
130
126
|
if (error) return createElement(Screen, { title }, createElement(ErrorState, {
|
|
131
127
|
message: error.message,
|
|
@@ -136,18 +132,9 @@ export function MediaPicker({ kind = "all", title = kind === "image" ? "Photos"
|
|
|
136
132
|
if (importing) return createElement(Screen, { title }, createElement(LoadingState, { label: "Preparing attachments…" }));
|
|
137
133
|
return createElement("MediaPickerScreen", { title, rightIcon: selected.size ? check : undefined, onRightPress: selected.size ? confirm : undefined },
|
|
138
134
|
items.length === 0 ? createElement(EmptyState, { title: kind === "video" ? "No videos" : kind === "image" ? "No photos" : "No photos or videos" }) :
|
|
139
|
-
createElement(
|
|
140
|
-
items
|
|
135
|
+
createElement(MediaGridView<MediaItem>, {
|
|
136
|
+
items, selected, selectionStyle: "selected", hasMore,
|
|
137
|
+
onPress: item => toggleSelected(item.id),
|
|
141
138
|
onLoadMore: async () => { if (active.current) await loadPage(active.current.signal); },
|
|
142
|
-
renderItem: nativeListRow((row: MediaItem[]) => createElement("MediaGridRow", null, row.map(item => createElement("MediaCell", {
|
|
143
|
-
key: item.id, src: item.src, selected: selected.has(item.id), video: item.mimeType.startsWith("video/"), checkIcon: checkCircleFilled, videoIcon: videoFileFilled,
|
|
144
|
-
onPress: () => setSelected(previous => { const next = new Set(previous); if (next.has(item.id)) next.delete(item.id); else next.add(item.id); return next; }),
|
|
145
|
-
}))), row => [Array.from({ length: 3 }, (_, index) => {
|
|
146
|
-
const item = row[index];
|
|
147
|
-
return { hidden: !item, src: item?.src ?? row[0].src, selected: !!item && selected.has(item.id),
|
|
148
|
-
video: !!item?.mimeType.startsWith("video/"), onPress: item && (() => setSelected(previous => {
|
|
149
|
-
const next = new Set(previous); if (next.has(item.id)) next.delete(item.id); else next.add(item.id); return next;
|
|
150
|
-
})) };
|
|
151
|
-
})], mediaTemplate),
|
|
152
139
|
}));
|
|
153
140
|
}
|