@idosgames/mcp 0.1.22 → 0.1.24

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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/registry/host.json +2 -2
  3. package/registry/index.json +173 -140
  4. package/registry/modules/ai.json +5 -5
  5. package/registry/modules/board-game.json +9 -9
  6. package/registry/modules/boosts.json +7 -7
  7. package/registry/modules/character.json +34 -12
  8. package/registry/modules/chat.json +3 -3
  9. package/registry/modules/collections.json +5 -5
  10. package/registry/modules/coop-events.json +5 -5
  11. package/registry/modules/craft.json +5 -5
  12. package/registry/modules/deals.json +5 -5
  13. package/registry/modules/events.json +5 -5
  14. package/registry/modules/friends.json +5 -5
  15. package/registry/modules/idle-rpg.json +93 -54
  16. package/registry/modules/inventory.json +5 -5
  17. package/registry/modules/invite.json +5 -5
  18. package/registry/modules/leaderboard.json +5 -5
  19. package/registry/modules/lootboxes.json +6 -6
  20. package/registry/modules/mailbox.json +5 -5
  21. package/registry/modules/marketplace.json +6 -6
  22. package/registry/modules/mini-game.json +3 -3
  23. package/registry/modules/multiplayer.json +7 -7
  24. package/registry/modules/premium.json +5 -5
  25. package/registry/modules/profile.json +3 -3
  26. package/registry/modules/pvp.json +7 -7
  27. package/registry/modules/quests.json +6 -6
  28. package/registry/modules/rewards.json +5 -5
  29. package/registry/modules/season.json +5 -5
  30. package/registry/modules/store.json +5 -5
  31. package/registry/modules/tutorials.json +3 -3
  32. package/registry/modules/voxelcraft.json +6 -6
  33. package/registry/modules/workshop.json +3 -3
  34. package/registry/skills/blockchain-system.json +1 -1
  35. package/registry/skills/community-marketing-system.json +1 -1
  36. package/registry/skills/idosgames-getting-started.json +1 -1
  37. package/registry/skills/idosgames-ui-kit.json +1 -1
  38. package/registry/skills/mailbox-system.json +1 -1
  39. package/registry/skills/match-system.json +2 -2
  40. package/registry/skills/multiplayer-realtime.json +2 -2
@@ -35,16 +35,16 @@
35
35
  "version": "0.1.1"
36
36
  },
37
37
  "dependencies": {
38
- "@idosgames/core": "0.20.0",
39
- "@idosgames/module-sdk": "0.8.1",
40
- "@idosgames/react": "0.4.2",
38
+ "@idosgames/core": "0.21.0",
39
+ "@idosgames/module-sdk": "0.8.2",
40
+ "@idosgames/react": "0.4.4",
41
41
  "react": "19.2.7"
42
42
  },
43
- "contentHash": "f5c0187a70b1b22166f4ff70aa228339d4ef0a35acd2837bc3a1129e52576910",
43
+ "contentHash": "baae3cd9af04f1f2286605348c8eed3570add2eec30357f0b0188d32a63cbe3d",
44
44
  "files": [
45
45
  {
46
46
  "path": "FriendsScreen.tsx",
47
- "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [unread, setUnread] = useState(0);\n useEffect(() => {\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n load();\n const id = setInterval(load, 5 * 60_000);\n const off = client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(\n () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n state?.Social as { IncomingCount?: number } | undefined,\n unread,\n ),\n ),\n [state?.Social, unread],\n );\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `2px solid color-mix(in srgb, ${v.text} 60%, transparent)`,\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
47
+ "content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n // Subscribed directly, not keyed on the state object: the cache patches the social counters in\n // place, and an effect that does not re-run leaves the badge lit after a request is answered.\n let unread = 0;\n const update = () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n client.data.user.state?.Social as\n { IncomingCount?: number } | undefined,\n unread,\n ),\n );\n const setUnread = (n: number) => {\n unread = n;\n update();\n };\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n update();\n load();\n const id = setInterval(load, 5 * 60_000);\n const offs = [\n client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n ),\n client.on(\"user:anyUpdated\", update),\n ];\n return () => {\n clearInterval(id);\n for (const off of offs) off();\n };\n }, [client]);\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: `2px solid color-mix(in srgb, ${v.text} 60%, transparent)`,\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
48
48
  },
49
49
  {
50
50
  "path": "i18n.ts",