diff --git a/.fallowrc.json b/.fallowrc.json new file mode 100644 index 0000000..320cbb5 --- /dev/null +++ b/.fallowrc.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", + "entry": ["src/index.{ts,tsx,js,jsx}", "src/main.{ts,tsx,js,jsx}"], + "workspaces": { + "packages": ["packages/*", "apps/*"] + }, + "duplicates": { + "minOccurrences": 3, + "ignore": ["**/scripts/**", "**.test.ts", "licenses/**"] + }, + "rules": {} +} diff --git a/.forgejo/workflows/cleanup.yml b/.forgejo/workflows/cleanup.yml index 48305c8..a8de85f 100644 --- a/.forgejo/workflows/cleanup.yml +++ b/.forgejo/workflows/cleanup.yml @@ -31,16 +31,18 @@ jobs: "$API/repos/$REPO/actions/runs?page=$PAGE&limit=50" \ -o runs.json - COUNT="$(jq '.workflow_runs | length' runs.json)" + COUNT="$(jq 'if type == "array" then length else (.workflow_runs // []) | length end' runs.json)" test "$COUNT" -gt 0 || break jq -r \ --arg cutoff "$CUTOFF" \ --arg current "$CURRENT_RUN_ID" \ - '.workflow_runs[] + 'if type == "array" then . else (.workflow_runs // []) end + | .[] + | select(type == "object") | select((.id | tostring) != $current) - | select(["success", "failure", "cancelled", "skipped", "succeeded", "failed"] | index(.status)) - | select(.created < $cutoff) + | select((.status // .conclusion) as $status | ["success", "failure", "cancelled", "skipped", "succeeded", "failed"] | index($status)) + | select((.created // .created_at) < $cutoff) | .id' runs.json \ >> "$DELETE_IDS" diff --git a/.gitignore b/.gitignore index a63f278..87d0fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules releases +.fallow/ diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 7ea7afc..6487ba1 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -163,9 +163,7 @@ function AppShell() { } function CallInit() { - useInitializeCall(); - - return null; + return useInitializeCall(); } const rootRoute = createRootRoute({ diff --git a/bun.lock b/bun.lock index f3f63a0..02db475 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@typescript-eslint/parser": "^8.59.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", + "fallow": "^2.86.0", "globals": "^17.5.0", "jsonc-parser": "^3.3.1", "prettier": "^3.8.3", @@ -465,6 +466,22 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@fallow-cli/darwin-arm64": ["@fallow-cli/darwin-arm64@2.86.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MOAi1tP+pho98AUEgmA5ziWSDsbr0KWQXNuk9GrptYDBsH0H+Jf9qdW8m3ilJXcgL5+RO8RTH/5Nfkhq0uoMYA=="], + + "@fallow-cli/darwin-x64": ["@fallow-cli/darwin-x64@2.86.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mzvGhbZZ8KSG2ZEKnVpiL3Vp9lX4fep2HI8eHt3VDuhMkz8t5n3WRBaBS7LLGTCfLDZ9QPDWhSNCTkx/Llhxw=="], + + "@fallow-cli/linux-arm64-gnu": ["@fallow-cli/linux-arm64-gnu@2.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1N/GCf48xp0Rh9T1JrBvKXAwn1V/+UdCsc2QgF5IrUnwD1JjTG1+/mMg5XwKmrJNPfznJzgzyYGlyfdBeJlqwA=="], + + "@fallow-cli/linux-arm64-musl": ["@fallow-cli/linux-arm64-musl@2.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/H8eK3JpLOq3l5ccEoVVuRssM528vYjk06fy9AcjIvVAGnESErfHQkmGkzAU3nquNL/tlqz88OoBvX+rayDKzQ=="], + + "@fallow-cli/linux-x64-gnu": ["@fallow-cli/linux-x64-gnu@2.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-svu6wYoCnb1zjZFGjWGtmf8PMiN3SLBn7HYrkA/hXsFK3VRQLFXzts2dJOJ2kHN18rhCExBCQcqukuc/SOoKiA=="], + + "@fallow-cli/linux-x64-musl": ["@fallow-cli/linux-x64-musl@2.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pOr4ka4lUIdExkxG0/5cofETE1QY0DI1hbnGJxfLu3Ui0gr9A0H1Dl+ipo5oMJbMFaUiEMAFRCrtNx/j3tA/fg=="], + + "@fallow-cli/win32-arm64-msvc": ["@fallow-cli/win32-arm64-msvc@2.86.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-GmUU/l3euQkNcp5i6ZaUttjTrg2WEYohRCLMqWOUrY0CNaj/ejZ3bhDC+ZZIJZVuWg7sEFACi8wbXhc0rwVb9g=="], + + "@fallow-cli/win32-x64-msvc": ["@fallow-cli/win32-x64-msvc@2.86.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6c+y+QrhfiRQGiqo0QbZ5uoMxuSFichOVzPbZtagvUSO6LGogyB0qH+vNL2kk74NpD6knSXO8vrebhnsBJeI7Q=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], @@ -1191,6 +1208,8 @@ "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], + "fallow": ["fallow@2.86.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@fallow-cli/darwin-arm64": "2.86.0", "@fallow-cli/darwin-x64": "2.86.0", "@fallow-cli/linux-arm64-gnu": "2.86.0", "@fallow-cli/linux-arm64-musl": "2.86.0", "@fallow-cli/linux-x64-gnu": "2.86.0", "@fallow-cli/linux-x64-musl": "2.86.0", "@fallow-cli/win32-arm64-msvc": "2.86.0", "@fallow-cli/win32-x64-msvc": "2.86.0" }, "bin": { "fallow": "bin/fallow", "fallow-lsp": "bin/fallow-lsp", "fallow-mcp": "bin/fallow-mcp" } }, "sha512-jHSTWCrvU2Llq7M0bDniTR2aqxWOwJO8BsV2IIl4lOfysJ0bd/3APu3qZ5wJZ+FE40t5DKlMEFQeURMpEb0Rig=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], diff --git a/flake.nix b/flake.nix index 3773515..41b621a 100644 --- a/flake.nix +++ b/flake.nix @@ -6,8 +6,8 @@ outputs = {nixpkgs, ...}: let systems = ["x86_64-linux"]; forAllSystems = nixpkgs.lib.genAttrs systems; - version = "0.0.6"; - x86_64DebHash = "sha256-rNlyNZFgYVRvJzX1sBz/qLHUdZoyfvHl5xuwb2BtU48="; + version = "0.0.7"; + x86_64DebHash = "sha256-gRkUEx1T7mrCgFEVp0X/ZiaGrKWzO+cCvv33DKCkB+o="; forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}"; in { packages = forAllSystems (system: let diff --git a/package.json b/package.json index 0c0e2b1..ae8ec19 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "format": "bunx prettier --write .", "lint": "bun scripts/lint-packages.ts", + "check": "fallow && bun run lint", "copy-licenses": "bun scripts/copy-licenses.ts", "copy-releases": "bun scripts/copy-releases.ts", "pre-build": "bun run lint && bun run build:packages", @@ -34,11 +35,12 @@ "@typescript-eslint/parser": "^8.59.1", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", + "fallow": "^2.86.0", "globals": "^17.5.0", + "jsonc-parser": "^3.3.1", "prettier": "^3.8.3", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.1", - "jsonc-parser": "^3.3.1" + "typescript-eslint": "^8.59.1" }, "overrides": { "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz", diff --git a/packages/call/src/components/invitePopup.tsx b/packages/call/src/components/invitePopup.tsx new file mode 100644 index 0000000..f531913 --- /dev/null +++ b/packages/call/src/components/invitePopup.tsx @@ -0,0 +1,58 @@ +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + Dialog, + DialogContent, +} from "@tensamin/ui"; +import Wrapper from "@tensamin/user/wrapper"; +import { PhoneIncoming, PhoneMissed } from "lucide-react"; + +export default function InvitePopup({ + open, + setOpen, + onAccept, + user, +}: { + open: boolean; + setOpen: (value: boolean) => void; + onAccept: (value: boolean) => void; + user: number; +}) { + return ( + ( + + + + + + {user.display.slice(0, 2).toUpperCase()} + + +

{user.display}

+
+ + +
+
+
+ )} + /> + ); +} diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 0d28c61..6f3955c 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -6,6 +6,7 @@ import { log, toast } from "@tensamin/shared/log"; import { ttp } from "@tensamin/shared/data"; import { useCrypto } from "@tensamin/crypto/context"; import { useStorage } from "@tensamin/storage/context"; +import { useSession } from "@tensamin/storage/session"; import { useUser } from "@tensamin/user/context"; import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; import { @@ -24,7 +25,6 @@ import { getLogger, } from "livekit-client"; import z from "zod"; -import { toast as sonnerToast } from "sonner"; import { createScreenShareController, type ScreenShareSession, @@ -33,6 +33,7 @@ import { getSpeakingDetector, disposeSpeakingDetector, } from "./speakingIndicator"; +import InvitePopup from "./components/invitePopup"; // logging setLogExtension( @@ -45,6 +46,11 @@ setLogExtension( type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; type CallView = "preview" | "focused" | "grid"; +type IncomingCallInvite = { + callId: string; + callSecret: string; + senderId: number; +}; type CurrentCallData = | (z.infer & { exists: boolean }) | null; @@ -83,6 +89,7 @@ type CallStore = { view: CallView; invitedUserId: number | null; callId: string | null; + incomingCallInvite: IncomingCallInvite | null; callSecret: string | null; livekitToken: string | null; currentCallData: CurrentCallData; @@ -837,6 +844,7 @@ export async function disconnect() { state: "closing", invitedUserId: null, callId: null, + incomingCallInvite: null, callSecret: null, livekitToken: null, currentCallData: null, @@ -883,7 +891,7 @@ export async function joinCall( log(2, "call", "purple", "Call creation initialised"); useCall.setState({ state: "encrypting", - invitedUserId: sendInvite ? userId : null, + invitedUserId: sendInvite && !existingCallId ? userId : null, }); if (callSecret) { @@ -999,6 +1007,7 @@ export function resetCallState() { view: "preview", invitedUserId: null, callId: null, + incomingCallInvite: null, callSecret: null, livekitToken: null, currentCallData: null, @@ -1050,6 +1059,7 @@ export const useCall = create(() => ({ view: "preview", invitedUserId: null, callId: null, + incomingCallInvite: null, callSecret: null, livekitToken: null, currentCallData: null, @@ -1080,10 +1090,12 @@ export function useInitializeCall() { const { send, subscribePush } = useTTP(); const { getSharedSecret, decryptText, encryptText } = useCrypto(); const { load } = useStorage(); + const { insertCall } = useSession(); const { get } = useUser(); const callId = useCall((state) => state.callId); const view = useCall((state) => state.view); + const incomingCallInvite = useCall((state) => state.incomingCallInvite); const listenersRegistered = useRef(false); const noiseFilter = useMemo( @@ -1113,30 +1125,54 @@ export function useInitializeCall() { }, [decryptText, encryptText, get, getSharedSecret, load, navigate, send]); const showCallingScreen = useCallback( - async (callId: string, callSecret: string, senderId: number) => { - const senderName = await get(senderId).then((data) => data.display); - - sonnerToast(`Incoming call from ${senderName}`, { - action: { - label: "Accept", - onClick: () => { - joinCall(senderId, callSecret, callId, false).catch((err) => { - log(1, "call", "red", "Failed to join call", err); - }); - }, - }, - cancel: { - label: "Decline", - onClick: () => { - log(2, "call", "purple", "Declined call invite", { - callId, - senderId, - }); - }, - }, + (callId: string, callSecret: string, senderId: number) => { + useCall.setState({ + incomingCallInvite: { callId, callSecret, senderId }, }); }, - [get], + [], + ); + + const setInvitePopupOpen = useCallback((open: boolean) => { + if (!open) { + useCall.setState({ incomingCallInvite: null }); + } + }, []); + + const respondToInvite = useCallback( + (accepted: boolean) => { + const invite = useCall.getState().incomingCallInvite; + + useCall.setState({ incomingCallInvite: null }); + + if (!invite) { + return; + } + + insertCall({ + call_id: invite.callId, + call_secret: invite.callSecret, + call_members: [invite.senderId], + }); + + if (accepted) { + joinCall( + invite.senderId, + invite.callSecret, + invite.callId, + false, + ).catch((err) => { + log(1, "call", "red", "Failed to join call", err); + }); + return; + } + + log(2, "call", "purple", "Declined call invite", { + callId: invite.callId, + senderId: invite.senderId, + }); + }, + [insertCall], ); // listen to call invites @@ -1482,4 +1518,13 @@ export function useInitializeCall() { }); }); }, [callId, send, view]); + + return incomingCallInvite ? ( + + ) : null; } diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 18cf792..60afa75 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -12,14 +12,9 @@ import { TooltipContent, TooltipTrigger, } from "@tensamin/ui"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@tensamin/ui"; import Wrapper from "@tensamin/user/wrapper"; import { useChat } from "../context"; +import MessageContextMenu from "./messageContextMenu"; function MessageComponent({ grouped, @@ -38,72 +33,66 @@ function MessageComponent({ // pt-3 is to get a gap between messages className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${actuallyFailed || message.message_state === "awaiting" ? "opacity-50" : ""}`} > - - - ( - <> - {grouped ? ( -

+ +

+ ( + <> + {grouped ? ( +

+ {new Date(message.send_time).toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + })} +

+ ) : ( + + + + {user.display.slice(0, 2).toUpperCase()} + + + )} + {message.failed && message.message_state === "awaiting" && ( + + +

Failed to send message

+
+ } /> +
+ )} +
+ {!grouped && ( +
+

{user.display}

+

{new Date(message.send_time).toLocaleString([], { hour: "2-digit", minute: "2-digit", })}

- ) : ( - - - - {user.display.slice(0, 2).toUpperCase()} - - - )} - {message.failed && message.message_state === "awaiting" && ( - - -

Failed to send message

-
- } /> -
- )} -
- {!grouped && ( -
-

{user.display}

-

- {new Date(message.send_time).toLocaleString([], { - hour: "2-digit", - minute: "2-digit", - })} -

-
- )} -
- - )} - /> -
- } - /> - - - - - - + )} + +
+ + )} + /> +
+ ); } diff --git a/packages/chat/src/components/messageContextMenu.tsx b/packages/chat/src/components/messageContextMenu.tsx new file mode 100644 index 0000000..dcbc707 --- /dev/null +++ b/packages/chat/src/components/messageContextMenu.tsx @@ -0,0 +1,242 @@ +import { + cn, + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, + Drawer, + DrawerContent, + DrawerTrigger, + useIsMobile, +} from "@tensamin/ui"; +import { Pin, Clipboard, Pen, Reply, Forward, Trash } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ReactElement, ReactNode } from "react"; + +async function copyText(text: string) { + await navigator.clipboard.writeText(text); +} + +type MenuComponents = { + Content: (props: { className?: string; children: ReactNode }) => ReactElement; + Group: (props: { children: ReactNode }) => ReactElement; + Item: (props: { + children: ReactNode; + className?: string; + disabled?: boolean; + onClick?: () => void | Promise; + variant?: "default" | "destructive"; + }) => ReactElement; + Separator: (props: { className?: string }) => ReactElement; + Sub: (props: { children: ReactNode }) => ReactElement; + SubTrigger: (props: { + children: ReactNode; + onClick?: () => void | Promise; + }) => ReactElement; + SubContent: (props: { children: ReactNode }) => ReactElement; +}; + +const desktopMenuComponents: MenuComponents = { + Content: ContextMenuContent, + Group: ContextMenuGroup, + Item: ContextMenuItem, + Separator: ContextMenuSeparator, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, +}; + +function getMobileMenuComponents(onClose: () => void): MenuComponents { + return { + Content: ({ className, children }) => ( + +
{children}
+
+ ), + Group: ({ children }) =>
{children}
, + Item: ({ children, className, disabled, onClick, variant = "default" }) => { + async function handleClick() { + await onClick?.(); + onClose(); + } + + return ( + + ); + }, + Separator: ({ className }) => ( +
+ ), + Sub: ({ children }) =>
{children}
, + SubTrigger: ({ children, onClick }) => { + async function handleClick() { + await onClick?.(); + onClose(); + } + + return ( + + ); + }, + SubContent: ({ children }) =>
{children}
, + }; +} + +function ReactionItems({ Item }: { Item: MenuComponents["Item"] }) { + return Cool item; +} + +function MessageMenuContent({ + components, + content, + devEnabled, + messageId, + onAddReaction, + showReactionItems = true, +}: { + components: MenuComponents; + content: string; + devEnabled: boolean; + messageId: number; + onAddReaction?: () => void | Promise; + showReactionItems?: boolean; +}) { + const { Content, Group, Item, Separator, Sub, SubContent, SubTrigger } = + components; + + return ( + + + + Add Reaction + {showReactionItems && ( + + + + )} + + +

Pin Message

+
+ copyText(content)} + > +

Copy Raw

+
+
+ + + +

Edit Message

+
+ +

Reply

+
+ +

Forward

+
+
+ + + +

Delete Message

+
+
+ {devEnabled && ( + <> + + + copyText(String(messageId))} + > +

Copy ID

+
+
+ + )} +
+ ); +} + +export default function MessageContextMenu({ + children, + content, + messageId, +}: { + children: ReactElement; + content: string; + messageId: number; +}) { + const isMobile = useIsMobile(); + const devEnabled = useMemo( + () => Number(localStorage.getItem("log_level")) >= 3, + [], + ); + const [mainDrawerOpen, setMainDrawerOpen] = useState(false); + const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false); + const mainDrawerComponents = getMobileMenuComponents(() => + setMainDrawerOpen(false), + ); + const reactionDrawerComponents = getMobileMenuComponents(() => + setReactionDrawerOpen(false), + ); + + if (isMobile) { + return ( + <> + + {children} + setReactionDrawerOpen(true)} + showReactionItems={false} + /> + + + + + + + + ); + } + + return ( + + + + + ); +} diff --git a/packages/chat/todo.md b/packages/chat/todo.md index 122f14e..63466f3 100644 --- a/packages/chat/todo.md +++ b/packages/chat/todo.md @@ -1 +1,2 @@ -- Add context menu to messages +- Implement context menu features +- Add default-emoji-hotkey in settings diff --git a/packages/storage/src/session.tsx b/packages/storage/src/session.tsx index d992a03..892f3e6 100644 --- a/packages/storage/src/session.tsx +++ b/packages/storage/src/session.tsx @@ -15,6 +15,7 @@ interface SessionContextType { calls: Calls; moveUserIdToTop: (userId: number) => void; insertContact: (userId: number) => void; + insertCall: (call: Calls[number]) => void; } const SessionContext = createContext(undefined); @@ -24,6 +25,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) { const { load, save } = useStorage(); const [contacts, setContacts] = useState([]); const [communities, setCommunities] = useState([]); + const [calls, setCalls] = useState([]); // Get cached data and merge fresh data useEffect(() => { @@ -51,6 +53,15 @@ export default function SessionProvider({ children }: { children: ReactNode }) { }); }, [load, freshContacts, freshCommunities]); + useEffect(() => { + setCalls((prevCalls) => [ + ...freshCalls, + ...prevCalls.filter( + (call) => !freshCalls.some((fresh) => fresh.call_id === call.call_id), + ), + ]); + }, [freshCalls]); + // Save data useEffect(() => { save("cached_contacts", contacts); @@ -86,14 +97,25 @@ export default function SessionProvider({ children }: { children: ReactNode }) { }); }; + const insertCall = (call: Calls[number]) => { + setCalls((prevCalls) => { + if (prevCalls.some((prevCall) => prevCall.call_id === call.call_id)) { + return prevCalls; + } + + return [call, ...prevCalls]; + }); + }; + return ( {children}