release(mobile): Release v0.5.7 (#5061)
* release(desktop): release v1.11.0 * docs(desktop): restore mobile release inputs * fix(desktop): use js-yaml ESM exports * fix(desktop): use js-yaml ESM exports * fix(desktop): package jsdom runtime dependencies * fix(desktop): package jsdom runtime dependencies * fix(desktop): use js-yaml ESM exports for Windows metadata * build(deps): bump actions/setup-node from 6 to 7 (#5046) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(ssr): escape hydration data in inline scripts * fix(ssr): restore shared user profiles * fix: make Codex worktree setup portable * fix(mobile): restore dark mode text colors * fix(styles): avoid DaisyUI border token collision * fix(desktop): restore category chevron rotation * fix(subscription): recover past-due Stripe checkout * fix(desktop): dismiss share popover after actions * fix(mobile): handle RSSHub subscription limit errors * fix(desktop/ipc): allowlist URL scheme protocols in openURLScheme (#5056) The 'integration.openURLScheme' IPC method invokes 'shell.openExternal' with a renderer-supplied string after only checking that it contains '://'. Electron's documentation explicitly warns that passing untrusted URLs to 'shell.openExternal' is unsafe: schemes such as 'file://', 'smb://', 'ms-msdt:', 'search-ms:', 'jar:', 'res:', 'javascript:', 'data:' and 'vbscript:' have well-known abuse chains (local file disclosure, NTLM credential theft over SMB on Windows, MSDT/Follina-style RCE, etc.). Because the renderer process can also reach this IPC via any XSS sink in untrusted RSS feed content, the previous validation was not sufficient. Replace the substring check with strict URL parsing plus an allowlist of protocols that match the integration use-cases documented in the UI (Obsidian, Bear, Drafts, Things, Notion, DEVONthink) plus generic http/https/mailto. All other protocols are rejected with a clear error. Adds vitest cases for representative dangerous schemes (verifying that 'shell.openExternal' is never invoked) and for every scheme shipped as a built-in example, so future regressions on either side are caught. * chore(ci): format integration URL scheme test * fix(mobile): use product IDs for Apple IAP verification * docs(desktop): prepare release inputs * docs(mobile): prepare release metadata * release(mobile): release v0.5.7 * docs(mobile): restore desktop release inputs --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sebastion <sebastion@sebastion.dev>
This commit is contained in:
commit
48ce50d034
|
|
@ -0,0 +1,37 @@
|
|||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "Folo"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm i
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "web dev"
|
||||
icon = "run"
|
||||
command = "pnpm dev:web"
|
||||
|
||||
[[actions]]
|
||||
name = "electron dev"
|
||||
icon = "run"
|
||||
command = '''
|
||||
cd apps/desktop
|
||||
pnpm dev:electron
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "ios dev"
|
||||
icon = "run"
|
||||
command = '''
|
||||
cd apps/mobile
|
||||
pnpm ios
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "android dev"
|
||||
icon = "run"
|
||||
command = '''
|
||||
cd apps/mobile
|
||||
pnpm android
|
||||
'''
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🏗 Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
@ -407,7 +407,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🏗 Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
@ -148,7 +148,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🏗 Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: 🏗 Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,11 @@ apps/desktop/resources/cli
|
|||
|
||||
.wrangler
|
||||
|
||||
# Local agent artifacts
|
||||
.codex/
|
||||
# Local agent artifacts, except the shared Codex environment
|
||||
.codex/*
|
||||
!.codex/environments/
|
||||
.codex/environments/*
|
||||
!.codex/environments/environment.toml
|
||||
|
||||
# E2E outputs
|
||||
/apps/desktop/e2e/playwright-report/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
# Files copied by Codex into managed worktrees
|
||||
apps/desktop/.env
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# What's new in v1.11.0
|
||||
|
||||
## Improvements
|
||||
|
||||
- Upgraded the desktop runtime to Electron 43 and refreshed core dependencies
|
||||
|
||||
## No longer broken
|
||||
|
||||
- Fixed YouTube embeds in entry content and readability view failing to play due to a missing referrer
|
||||
|
||||
## Thanks
|
||||
|
||||
Special thanks to volunteer contributor @TonyRL for the YouTube embed fix
|
||||
|
|
@ -36,7 +36,43 @@ const ymlMapsMap = {
|
|||
win32: "latest.yml",
|
||||
}
|
||||
|
||||
const keepModules = new Set(["font-list", "vscode-languagedetection"])
|
||||
// Keep external runtime modules and their production dependency trees in app.asar.
|
||||
// Scoped packages are copied as a whole because cleanSources operates on top-level entries.
|
||||
const keepModules = new Set([
|
||||
"@asamuzakjp",
|
||||
"@bramus",
|
||||
"@csstools",
|
||||
"@exodus",
|
||||
"bidi-js",
|
||||
"css-tree",
|
||||
"data-urls",
|
||||
"decimal.js",
|
||||
"entities",
|
||||
"font-list",
|
||||
"html-encoding-sniffer",
|
||||
"is-potential-custom-element-name",
|
||||
"jsdom",
|
||||
"lru-cache",
|
||||
"mdn-data",
|
||||
"parse5",
|
||||
"punycode",
|
||||
"require-from-string",
|
||||
"saxes",
|
||||
"source-map-js",
|
||||
"symbol-tree",
|
||||
"tldts",
|
||||
"tldts-core",
|
||||
"tough-cookie",
|
||||
"tr46",
|
||||
"undici",
|
||||
"vscode-languagedetection",
|
||||
"w3c-xmlserializer",
|
||||
"webidl-conversions",
|
||||
"whatwg-mimetype",
|
||||
"whatwg-url",
|
||||
"xml-name-validator",
|
||||
"xmlchars",
|
||||
])
|
||||
const keepLanguages = new Set(["en", "en_GB", "en-US", "en_US"])
|
||||
|
||||
// remove folders & files not to be included in the app
|
||||
|
|
@ -93,7 +129,10 @@ async function cleanSources(buildPath, _electronVersion, platform, _arch, callba
|
|||
|
||||
const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) => callback()
|
||||
|
||||
const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")})`)
|
||||
const keepModulePattern = [...keepModules]
|
||||
.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("|")
|
||||
const ignorePattern = new RegExp(`^/node_modules/(?!(?:${keepModulePattern})(?:/|$))`)
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import fsp from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
|
||||
import { shell } from "electron"
|
||||
import path from "pathe"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { IntegrationService } from "./integration"
|
||||
|
||||
|
|
@ -69,4 +70,70 @@ describe("IntegrationService", () => {
|
|||
fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1")),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
describe("openURLScheme", () => {
|
||||
const openExternalMock = vi.mocked(shell.openExternal)
|
||||
|
||||
beforeEach(() => {
|
||||
openExternalMock.mockReset()
|
||||
openExternalMock.mockResolvedValue()
|
||||
})
|
||||
|
||||
it("rejects input that cannot be parsed as a URL", async () => {
|
||||
const service = new IntegrationService()
|
||||
|
||||
await expect(service.openURLScheme("not-a-url")).rejects.toThrow(/Invalid URL scheme/i)
|
||||
expect(openExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// These are the dangerous protocols that previously slipped through the
|
||||
// "contains ://" guard and reached shell.openExternal verbatim.
|
||||
// shell.openExternal docs explicitly warn that passing untrusted URLs is
|
||||
// unsafe — file://, smb://, search-ms:, ms-msdt:, jar:, res:, etc. have
|
||||
// been used in real-world RCE / NTLM-credential-theft chains.
|
||||
it.each([
|
||||
["file:///etc/passwd"],
|
||||
["FILE:///etc/passwd"],
|
||||
["smb://attacker.example/share"],
|
||||
["jar:http://attacker.example/x.jar!/"],
|
||||
["res://shell32.dll/1"],
|
||||
["ms-msdt:/id PCWDiagnostic"],
|
||||
["search-ms:query=secret"],
|
||||
["javascript:alert(1)"],
|
||||
["data:text/html,<script>alert(1)</script>"],
|
||||
["vbscript:msgbox(1)"],
|
||||
])(
|
||||
"blocks dangerous scheme %s and does not invoke shell.openExternal",
|
||||
async (dangerousScheme) => {
|
||||
const service = new IntegrationService()
|
||||
|
||||
await expect(service.openURLScheme(dangerousScheme)).rejects.toThrow(
|
||||
/not allowed|disallowed|not permitted/i,
|
||||
)
|
||||
expect(openExternalMock).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
// The integration UI ships these schemes as built-in examples
|
||||
// (see url-scheme-handler.ts#getExamples) plus generic web/mail.
|
||||
// They must keep working after the fix.
|
||||
it.each([
|
||||
["https://example.com"],
|
||||
["http://example.com/path?q=1"],
|
||||
["mailto:user@example.com"],
|
||||
["obsidian://new?vault=MyVault&name=Test"],
|
||||
["bear://x-callback-url/create?title=Test"],
|
||||
["things:///add?title=Test"],
|
||||
["notion://new?title=Test"],
|
||||
["x-devonthink://createText?title=Test"],
|
||||
["drafts://x-callback-url/create?text=Test"],
|
||||
])("permits known integration scheme %s", async (allowedScheme) => {
|
||||
const service = new IntegrationService()
|
||||
|
||||
await expect(service.openURLScheme(allowedScheme)).resolves.toEqual({
|
||||
success: true,
|
||||
})
|
||||
expect(openExternalMock).toHaveBeenCalledWith(allowedScheme)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -81,6 +81,29 @@ export async function saveMediaToEagle(input: SaveToEagleInput): Promise<any> {
|
|||
}
|
||||
}
|
||||
|
||||
// Allowlist of URL scheme protocols that `openURLScheme` is permitted to hand
|
||||
// off to `shell.openExternal`. The list intentionally covers the integrations
|
||||
// shipped in the UI (Obsidian, Bear, Drafts, Things, Notion, DEVONthink) plus
|
||||
// generic web/mail schemes, while excluding dangerous protocols such as
|
||||
// `file:`, `smb:`, `ms-msdt:`, `search-ms:`, `jar:`, `res:`, `javascript:`,
|
||||
// `data:`, `vbscript:`, which have known abuse chains when invoked from
|
||||
// untrusted content.
|
||||
const ALLOWED_URL_SCHEME_PROTOCOLS = new Set<string>([
|
||||
"http",
|
||||
"https",
|
||||
"mailto",
|
||||
"obsidian",
|
||||
"bear",
|
||||
"drafts",
|
||||
"things",
|
||||
"notion",
|
||||
"x-devonthink",
|
||||
])
|
||||
|
||||
function isAllowedURLSchemeProtocol(protocol: string): boolean {
|
||||
return ALLOWED_URL_SCHEME_PROTOCOLS.has(protocol)
|
||||
}
|
||||
|
||||
export class IntegrationService extends IpcService {
|
||||
static override readonly groupName = "integration"
|
||||
|
||||
|
|
@ -382,11 +405,32 @@ ${content}
|
|||
const requestId = Math.random().toString(36).slice(2, 8)
|
||||
|
||||
try {
|
||||
// Validate URL scheme format
|
||||
if (!scheme.includes("://")) {
|
||||
// Parse and validate the protocol up-front. `shell.openExternal` will
|
||||
// happily dispatch any scheme the OS has registered a handler for,
|
||||
// including `file://`, `smb://`, `ms-msdt:`, `search-ms:`, `jar:`,
|
||||
// `res:`, etc. Several of those have well-documented exploit chains
|
||||
// (NTLM credential theft over SMB, MSDT/Follina RCE on Windows,
|
||||
// local-file disclosure via file://). The Electron docs explicitly
|
||||
// warn against passing untrusted URLs to `shell.openExternal`, so we
|
||||
// enforce a strict allowlist of schemes that the integrations UI is
|
||||
// intended to support.
|
||||
let protocol: string
|
||||
try {
|
||||
protocol = new URL(scheme).protocol.replace(/:$/, "").toLowerCase()
|
||||
} catch {
|
||||
throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')")
|
||||
}
|
||||
|
||||
if (!protocol) {
|
||||
throw new Error("Invalid URL scheme format. Must include protocol (e.g., 'app://')")
|
||||
}
|
||||
|
||||
if (!isAllowedURLSchemeProtocol(protocol)) {
|
||||
throw new Error(
|
||||
`URL scheme "${protocol}://" is not allowed. Allowed schemes: ${[...ALLOWED_URL_SCHEME_PROTOCOLS].sort().join(", ")}.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Log URL scheme execution (mask sensitive data)
|
||||
const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) =>
|
||||
// Mask potential sensitive query parameters
|
||||
|
|
@ -399,7 +443,7 @@ ${content}
|
|||
|
||||
logger.info(`[URLScheme:${requestId}] Opening URL scheme`, {
|
||||
scheme: safeScheme,
|
||||
protocol: scheme.split("://")[0],
|
||||
protocol,
|
||||
})
|
||||
|
||||
// Use Electron's shell.openExternal to open URL scheme
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { createAtomHooks, jotaiStore } from "~/lib/jotai"
|
|||
export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
|
||||
/** Custom z-index for popover */
|
||||
zIndex?: number
|
||||
/** Whether the popover should close when clicked outside */
|
||||
/** Whether the popover should use modal focus and pointer behavior */
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +33,11 @@ export const showPopover = (
|
|||
element: ReactNode,
|
||||
props?: PopoverProps,
|
||||
) => {
|
||||
const currentPopover = jotaiStore.get(popoverAtom)
|
||||
if (currentPopover.open) {
|
||||
currentPopover.abortController.abort()
|
||||
}
|
||||
|
||||
jotaiStore.set(popoverAtom, {
|
||||
open: true,
|
||||
position: mouseXY,
|
||||
|
|
@ -41,3 +46,11 @@ export const showPopover = (
|
|||
abortController: new AbortController(),
|
||||
})
|
||||
}
|
||||
|
||||
export const dismissPopover = () => {
|
||||
const currentPopover = jotaiStore.get(popoverAtom)
|
||||
if (!currentPopover.open) return
|
||||
|
||||
currentPopover.abortController.abort()
|
||||
jotaiStore.set(popoverAtom, { open: false })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import * as React from "react"
|
||||
import { act } from "react"
|
||||
import type { Root } from "react-dom/client"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"
|
||||
|
||||
import { SharePanel } from "./SharePanel"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
dismissPopover: vi.fn(),
|
||||
getEntry: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@follow/store/entry/getter", () => ({
|
||||
getEntry: mocks.getEntry,
|
||||
}))
|
||||
|
||||
vi.mock("~/atoms/popover", () => ({
|
||||
dismissPopover: mocks.dismissPopover,
|
||||
}))
|
||||
|
||||
vi.mock("~/lib/client", () => ({
|
||||
ipcServices: undefined,
|
||||
}))
|
||||
|
||||
vi.mock("~/lib/clipboard", () => ({
|
||||
copyToClipboard: mocks.copyToClipboard,
|
||||
}))
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}))
|
||||
|
||||
const waitForShareAction = async () => {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
const renderSharePanel = async () => {
|
||||
const container = document.createElement("div")
|
||||
document.body.append(container)
|
||||
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(<SharePanel entryId="entry-1" />)
|
||||
})
|
||||
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
const clickAction = async (container: HTMLElement, label: string) => {
|
||||
const button = Array.from(container.querySelectorAll("button")).find((element) =>
|
||||
element.textContent?.includes(label),
|
||||
)
|
||||
expect(button).not.toBeUndefined()
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
|
||||
await waitForShareAction()
|
||||
})
|
||||
}
|
||||
|
||||
describe("SharePanel", () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLElement | null = null
|
||||
|
||||
beforeAll(() => {
|
||||
;(globalThis as typeof globalThis & { React: typeof React }).React = React
|
||||
;(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.getEntry.mockReturnValue({
|
||||
description: "Example description",
|
||||
id: "entry-1",
|
||||
title: "Example entry",
|
||||
url: "https://example.com/article",
|
||||
})
|
||||
mocks.copyToClipboard.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
document.body.innerHTML = ""
|
||||
root = null
|
||||
container = null
|
||||
Reflect.deleteProperty(navigator, "share")
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test("dismisses after copying the link", async () => {
|
||||
;({ container, root } = await renderSharePanel())
|
||||
|
||||
await clickAction(container, "share.copy_link")
|
||||
|
||||
expect(mocks.copyToClipboard).toHaveBeenCalledWith("https://example.com/article")
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith("share.link_copied")
|
||||
expect(mocks.dismissPopover).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test("keeps the panel open when copying the link fails", async () => {
|
||||
mocks.copyToClipboard.mockRejectedValueOnce(new Error("Clipboard unavailable"))
|
||||
;({ container, root } = await renderSharePanel())
|
||||
|
||||
await clickAction(container, "share.copy_link")
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith("share.copy_failed")
|
||||
expect(mocks.dismissPopover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("dismisses after system sharing completes", async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, "share", {
|
||||
configurable: true,
|
||||
value: share,
|
||||
})
|
||||
;({ container, root } = await renderSharePanel())
|
||||
|
||||
await clickAction(container, "share.system_share")
|
||||
|
||||
expect(share).toHaveBeenCalledWith({
|
||||
text: "Example description | share.discover_more",
|
||||
title: "Example entry - Folo",
|
||||
url: "https://example.com/article",
|
||||
})
|
||||
expect(mocks.dismissPopover).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ import { useCallback } from "react"
|
|||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { dismissPopover } from "~/atoms/popover"
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
|
||||
|
|
@ -140,11 +141,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
}
|
||||
dismissPopover()
|
||||
} catch {
|
||||
// If sharing fails, copy link as fallback
|
||||
try {
|
||||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
dismissPopover()
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
}
|
||||
|
|
@ -156,6 +159,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
try {
|
||||
await copyToClipboard(shareUrl)
|
||||
toast.success(t("share.link_copied"))
|
||||
dismissPopover()
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
}
|
||||
|
|
@ -178,6 +182,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
.replace("{text}", shareText)
|
||||
|
||||
window.open(finalUrl, "_blank", "width=600,height=400")
|
||||
dismissPopover()
|
||||
},
|
||||
[entryId, generateShareContent],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"
|
|||
import type { TFunction } from "i18next"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs"
|
||||
import { useIsPaymentEnabled, useServerConfigs } from "~/atoms/server-configs"
|
||||
|
|
@ -17,6 +18,15 @@ import { followClient } from "~/lib/api-client"
|
|||
import { subscription } from "~/lib/auth"
|
||||
|
||||
const APPLE_SUBSCRIPTION_MANAGEMENT_URL = "https://apps.apple.com/account/subscriptions"
|
||||
const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS"
|
||||
|
||||
type BillingPortalResponse = {
|
||||
code: number
|
||||
data?: {
|
||||
url: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ActiveSubscription = {
|
||||
source: "stripe" | "apple" | null
|
||||
|
|
@ -83,6 +93,29 @@ const formatFeatureValue = (
|
|||
return value
|
||||
}
|
||||
|
||||
const openStripeBillingPortal = async () => {
|
||||
const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href
|
||||
const res = await fetch(`${env.VITE_API_URL}/billing/portal`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ returnUrl }),
|
||||
})
|
||||
const data = (await res.json()) as BillingPortalResponse
|
||||
if (!res.ok || data.code !== 0 || !data.data?.url) {
|
||||
throw new Error(data.message || "Failed to open billing portal")
|
||||
}
|
||||
|
||||
if (IN_ELECTRON) {
|
||||
window.open(data.data.url, "_blank")
|
||||
return
|
||||
}
|
||||
|
||||
window.location.assign(data.data.url)
|
||||
}
|
||||
|
||||
const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: boolean }) => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
|
|
@ -97,10 +130,20 @@ const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: bo
|
|||
cancelUrl: env.VITE_WEB_URL,
|
||||
disableRedirect: IN_ELECTRON,
|
||||
})
|
||||
if (res.error?.code === ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE) {
|
||||
await openStripeBillingPortal()
|
||||
return
|
||||
}
|
||||
if (res.error) {
|
||||
throw new Error(res.error.message)
|
||||
}
|
||||
if (IN_ELECTRON && res.data?.url) {
|
||||
window.open(res.data.url, "_blank")
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -120,20 +163,9 @@ const useActiveSubscription = () => {
|
|||
|
||||
const useBillingPortal = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href
|
||||
const res = await fetch(`${env.VITE_API_URL}/billing/portal`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ returnUrl }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && data.data?.url) {
|
||||
window.open(data.data.url, "_blank")
|
||||
}
|
||||
mutationFn: openStripeBillingPortal,
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ function FeedCategoryImpl({
|
|||
onClick={handleCollapseButtonClick}
|
||||
data-state={open ? "open" : "close"}
|
||||
className={cn(
|
||||
"flex h-8 items-center [&_.i-mgc-right-cute-fi]:data-[state=open]:rotate-90",
|
||||
"flex h-8 items-center data-[state=open]:[&_.i-mgc-right-cute-fi]:rotate-90",
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { GlobalFocusableProvider } from "@follow/components/common/Focusable/GlobalFocusableProvider.js"
|
||||
import { Provider } from "jotai"
|
||||
import * as React from "react"
|
||||
import { act } from "react"
|
||||
import type { Root } from "react-dom/client"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"
|
||||
|
||||
import { dismissPopover, popoverAtom, showPopover } from "~/atoms/popover"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
|
||||
import { PopoverProvider } from "./popover-provider"
|
||||
|
||||
const waitForPopoverEffects = async () => {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
const renderProvider = async () => {
|
||||
const container = document.createElement("div")
|
||||
document.body.append(container)
|
||||
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={jotaiStore}>
|
||||
<GlobalFocusableProvider>
|
||||
<PopoverProvider>
|
||||
<div>App content</div>
|
||||
</PopoverProvider>
|
||||
</GlobalFocusableProvider>
|
||||
</Provider>,
|
||||
)
|
||||
})
|
||||
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
describe("PopoverProvider", () => {
|
||||
let root: Root | null = null
|
||||
let container: HTMLElement | null = null
|
||||
|
||||
beforeAll(() => {
|
||||
;(globalThis as typeof globalThis & { React: typeof React }).React = React
|
||||
;(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.spyOn(console, "info").mockImplementation(() => {})
|
||||
|
||||
Object.assign(window, {
|
||||
addEventListener: document.defaultView?.addEventListener.bind(document.defaultView),
|
||||
clearTimeout,
|
||||
Element: document.defaultView?.Element ?? Element,
|
||||
getComputedStyle:
|
||||
document.defaultView?.getComputedStyle.bind(document.defaultView) ?? getComputedStyle,
|
||||
HTMLElement: document.defaultView?.HTMLElement ?? HTMLElement,
|
||||
innerHeight: 768,
|
||||
innerWidth: 1024,
|
||||
Node: document.defaultView?.Node ?? Node,
|
||||
removeEventListener: document.defaultView?.removeEventListener.bind(document.defaultView),
|
||||
setTimeout,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
jotaiStore.set(popoverAtom, { open: false })
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
document.body.innerHTML = ""
|
||||
root = null
|
||||
container = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test("closes when clicking outside", async () => {
|
||||
;({ container, root } = await renderProvider())
|
||||
|
||||
await act(async () => {
|
||||
showPopover({ x: 120, y: 80 }, <div>Share content</div>)
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain("Share content")
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
|
||||
const appContent = Array.from(container.querySelectorAll("div")).find(
|
||||
(element) => element.textContent === "App content",
|
||||
)
|
||||
expect(appContent).not.toBeUndefined()
|
||||
|
||||
await act(async () => {
|
||||
for (const eventType of ["pointerdown", "pointerup", "click"]) {
|
||||
appContent?.dispatchEvent(
|
||||
new PointerEvent(eventType, {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(jotaiStore.get(popoverAtom).open).toBe(false)
|
||||
expect(document.body.textContent).not.toContain("Share content")
|
||||
})
|
||||
|
||||
test("can reopen after a programmatic dismissal", async () => {
|
||||
;({ container, root } = await renderProvider())
|
||||
|
||||
await act(async () => {
|
||||
showPopover({ x: 120, y: 80 }, <div>First popover</div>)
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain("First popover")
|
||||
|
||||
await act(async () => {
|
||||
dismissPopover()
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(document.body.textContent).not.toContain("First popover")
|
||||
|
||||
await act(async () => {
|
||||
showPopover({ x: 140, y: 100 }, <div>Second popover</div>)
|
||||
await waitForPopoverEffects()
|
||||
})
|
||||
|
||||
expect(jotaiStore.get(popoverAtom).open).toBe(true)
|
||||
expect(document.body.textContent).toContain("Second popover")
|
||||
})
|
||||
})
|
||||
|
|
@ -8,9 +8,9 @@ import {
|
|||
PopoverTrigger,
|
||||
} from "@follow/components/ui/popover/index.jsx"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo, useEffect, useRef } from "react"
|
||||
import { memo, useEffect } from "react"
|
||||
|
||||
import { usePopoverState } from "~/atoms/popover"
|
||||
import { dismissPopover, usePopoverValue } from "~/atoms/popover"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
|
||||
export const PopoverProvider: Component = ({ children }) => (
|
||||
|
|
@ -21,36 +21,31 @@ export const PopoverProvider: Component = ({ children }) => (
|
|||
)
|
||||
|
||||
const Handler = memo(() => {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
const [popoverState, setPopoverState] = usePopoverState()
|
||||
const popoverState = usePopoverValue()
|
||||
const setGlobalFocusableScope = useSetGlobalFocusableScope()
|
||||
|
||||
useEffect(() => {
|
||||
if (!popoverState.open) return
|
||||
const triggerElement = ref.current
|
||||
if (!triggerElement) return
|
||||
|
||||
triggerElement.dispatchEvent(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}, [popoverState])
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
|
||||
return () => {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
|
||||
}
|
||||
}, [popoverState.open, setGlobalFocusableScope])
|
||||
|
||||
const { modal, zIndex, ...contentProps } = popoverState.open ? (popoverState.props ?? {}) : {}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popoverState.open}
|
||||
modal={modal}
|
||||
onOpenChange={(state) => {
|
||||
if (state) {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
|
||||
} else {
|
||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
|
||||
setPopoverState({ open: false })
|
||||
if (!state) {
|
||||
dismissPopover()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
ref={ref}
|
||||
className="pointer-events-none"
|
||||
style={
|
||||
popoverState.open
|
||||
|
|
@ -58,9 +53,14 @@ const Handler = memo(() => {
|
|||
: {}
|
||||
}
|
||||
/>
|
||||
<PopoverContent asChild forceMount>
|
||||
<AnimatePresence>
|
||||
{popoverState.open && (
|
||||
<AnimatePresence>
|
||||
{popoverState.open && (
|
||||
<PopoverContent
|
||||
{...contentProps}
|
||||
asChild
|
||||
forceMount
|
||||
style={{ ...contentProps.style, zIndex }}
|
||||
>
|
||||
<m.div
|
||||
className="mr-2 rounded-xl border bg-material-ultra-thick p-2 shadow-2xl backdrop-blur-background"
|
||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
|
|
@ -71,9 +71,9 @@ const Handler = memo(() => {
|
|||
<PopoverArrow className="fill-border" />
|
||||
{popoverState.content}
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</PopoverContent>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "Folo",
|
||||
"type": "module",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"description": "Follow everything in one place",
|
||||
"author": "Folo Team",
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
"workbox-build": "7.4.1",
|
||||
"workbox-window": "7.4.1"
|
||||
},
|
||||
"runtimeVersion": "1.9.0",
|
||||
"runtimeVersion": "1.11.0",
|
||||
"productName": "Folo",
|
||||
"mainHash": "79b9a1c73ebea4fc92b28cc4952a2fe92557557141b1de0c81074622d8a1f404"
|
||||
"mainHash": "b672f321b6478cbaad0ad6e354d73ca3c8a79f90aaadb41c119499639ec66e87"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": "1.10.0",
|
||||
"mode": "ota",
|
||||
"runtimeVersion": "1.9.0",
|
||||
"channel": "stable"
|
||||
"version": "1.11.0",
|
||||
"mode": "build",
|
||||
"runtimeVersion": null,
|
||||
"channel": null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import fs from "node:fs"
|
||||
|
||||
import yaml from "js-yaml"
|
||||
import * as yaml from "js-yaml"
|
||||
import path from "pathe"
|
||||
|
||||
const outDir = "./out/make"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import crypto from "node:crypto"
|
|||
import fs from "node:fs"
|
||||
import { fileURLToPath, resolve } from "node:url"
|
||||
|
||||
import yaml from "js-yaml"
|
||||
import * as yaml from "js-yaml"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const basePath = resolve(__dirname, "../out/make/squirrel.windows/x64/")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# What's New in v0.5.7
|
||||
|
||||
## Improvements
|
||||
|
||||
- Improved RSSHub subscription-limit errors with localized upgrade guidance and without internal request details
|
||||
|
||||
## No longer broken
|
||||
|
||||
- Fixed Apple subscription purchases and restores failing when product and transaction identifiers were confused
|
||||
- Fixed upgrades for active or past-due Stripe subscribers by opening billing management
|
||||
- Restored readable text colors in dark mode
|
||||
- Fixed border styling in web-rendered content affected by a shared CSS token collision
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.5.6</string>
|
||||
<string>0.5.7</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>9</string>
|
||||
<string>10</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@follow/mobile",
|
||||
"version": "0.5.6",
|
||||
"version": "0.5.7",
|
||||
"private": true,
|
||||
"main": "src/main.tsx",
|
||||
"scripts": {
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
"react-native-sheet-transitions": "0.1.2",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-track-player": "4.1.2",
|
||||
"react-native-uikit-colors": "1.0.0",
|
||||
"react-native-uikit-colors": "0.6.2",
|
||||
"react-native-volume-manager": "2.0.8",
|
||||
"react-native-web": "0.21.2",
|
||||
"react-native-webview": "14.0.1",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "0.5.6",
|
||||
"version": "0.5.7",
|
||||
"mode": "store",
|
||||
"runtimeVersion": null,
|
||||
"channel": null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import postcss from "postcss"
|
||||
import { cssToReactNativeRuntime } from "react-native-css-interop/css-to-rn"
|
||||
import { withUIKit } from "react-native-uikit-colors/tailwind"
|
||||
import tailwindcss from "tailwindcss"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const alphaColorClasses = [
|
||||
"border-separator",
|
||||
"border-non-opaque-separator",
|
||||
"bg-system-fill",
|
||||
"bg-secondary-system-fill",
|
||||
"bg-tertiary-system-fill",
|
||||
"bg-quaternary-system-fill",
|
||||
"text-secondary-label",
|
||||
"text-tertiary-label",
|
||||
"text-quaternary-label",
|
||||
]
|
||||
|
||||
describe("UIKit alpha colors", () => {
|
||||
it("compiles semantic colors for the native runtime", async () => {
|
||||
const config = withUIKit({
|
||||
content: [{ raw: alphaColorClasses.join(" ") }],
|
||||
})
|
||||
const { css } = await postcss([tailwindcss(config)]).process("@tailwind utilities;", {
|
||||
from: undefined,
|
||||
})
|
||||
const compiled = cssToReactNativeRuntime(css)
|
||||
|
||||
for (const className of alphaColorClasses) {
|
||||
const ruleSet = compiled.rules?.[className]
|
||||
const hasDeclarations = ruleSet?.n?.some((rule) => (rule.d?.length ?? 0) > 0)
|
||||
|
||||
expect(ruleSet?.warnings, className).toBeUndefined()
|
||||
expect(hasDeclarations, className).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { sanitizeErrorMessage } from "./error-message"
|
||||
|
||||
describe("sanitizeErrorMessage", () => {
|
||||
it("removes Follow API request context from display messages", () => {
|
||||
const message = [
|
||||
"RSSHub feed subscription limit exceeded",
|
||||
"Request: POST /subscriptions (original: /subscriptions)",
|
||||
"Args: {",
|
||||
' "headers": {',
|
||||
' "cookie": "session=secret"',
|
||||
" }",
|
||||
"}",
|
||||
].join("\n")
|
||||
|
||||
expect(sanitizeErrorMessage(message)).toBe("RSSHub feed subscription limit exceeded")
|
||||
})
|
||||
|
||||
it("supports CRLF request context", () => {
|
||||
expect(
|
||||
sanitizeErrorMessage("Subscription limit exceeded\r\nRequest: POST /subscriptions"),
|
||||
).toBe("Subscription limit exceeded")
|
||||
})
|
||||
|
||||
it("preserves ordinary error messages", () => {
|
||||
expect(sanitizeErrorMessage("Unable to follow this feed")).toBe("Unable to follow this feed")
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
const FOLLOW_API_REQUEST_CONTEXT_PATTERN = /\r?\nRequest:[\s\S]*$/u
|
||||
|
||||
export const sanitizeErrorMessage = (message: string) =>
|
||||
message.replace(FOLLOW_API_REQUEST_CONTEXT_PATTERN, "").trim()
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { FollowAPIError } from "@follow-app/client-sdk"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { toastFetchError } from "./error-parser"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isPaymentEnabled: false,
|
||||
showUpgradeRequiredDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key: string) => {
|
||||
if (key === "errors:2012") {
|
||||
return "RSSHub feed subscription limit exceeded"
|
||||
}
|
||||
return key.replace(/^errors:/u, "")
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/src/atoms/server-configs", () => ({
|
||||
getIsPaymentEnabled: () => mocks.isPaymentEnabled,
|
||||
}))
|
||||
|
||||
vi.mock("@/src/modules/dialogs/UpgradeRequiredDialog", () => ({
|
||||
showUpgradeRequiredDialog: mocks.showUpgradeRequiredDialog,
|
||||
}))
|
||||
|
||||
vi.mock("./toast", () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
},
|
||||
}))
|
||||
|
||||
describe("toastFetchError", () => {
|
||||
beforeEach(() => {
|
||||
mocks.isPaymentEnabled = false
|
||||
mocks.showUpgradeRequiredDialog.mockClear()
|
||||
mocks.toastError.mockClear()
|
||||
})
|
||||
|
||||
it("shows a concise upgrade dialog for RSSHub subscription limits", () => {
|
||||
mocks.isPaymentEnabled = true
|
||||
const error = new FollowAPIError(
|
||||
[
|
||||
"RSSHub feed subscription limit exceeded",
|
||||
"Request: POST /subscriptions (original: /subscriptions)",
|
||||
'Args: { "headers": { "cookie": "session=secret" } }',
|
||||
].join("\n"),
|
||||
402,
|
||||
"2012",
|
||||
)
|
||||
|
||||
toastFetchError(error)
|
||||
|
||||
expect(mocks.showUpgradeRequiredDialog).toHaveBeenCalledWith({
|
||||
title: "RSSHub feed subscription limit exceeded",
|
||||
message: "settings:subscription.summary.free_description",
|
||||
})
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not expose request context when an API error code has no translation", () => {
|
||||
const error = new FollowAPIError(
|
||||
[
|
||||
"Unable to follow this feed",
|
||||
"Request: POST /subscriptions (original: /subscriptions)",
|
||||
'Args: { "headers": { "cookie": "session=secret" } }',
|
||||
].join("\n"),
|
||||
400,
|
||||
"29999",
|
||||
)
|
||||
|
||||
toastFetchError(error)
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith("Unable to follow this feed")
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ import { FetchError } from "ofetch"
|
|||
import { getIsPaymentEnabled } from "@/src/atoms/server-configs"
|
||||
import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog"
|
||||
|
||||
import { sanitizeErrorMessage } from "./error-message"
|
||||
import { toast } from "./toast"
|
||||
|
||||
export const getFetchErrorInfo = (
|
||||
|
|
@ -21,11 +22,11 @@ export const getFetchErrorInfo = (
|
|||
const i18nKey = `errors:${code}` as any
|
||||
const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)
|
||||
return {
|
||||
message: `${i18nMessage}${reason ? `: ${reason}` : ""}`,
|
||||
message: sanitizeErrorMessage(`${i18nMessage}${reason ? `: ${reason}` : ""}`),
|
||||
code,
|
||||
}
|
||||
} catch {
|
||||
return { message: error.message }
|
||||
return { message: sanitizeErrorMessage(error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,15 +36,15 @@ export const getFetchErrorInfo = (
|
|||
const i18nKey = `errors:${code}` as any
|
||||
const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)
|
||||
return {
|
||||
message: i18nMessage,
|
||||
message: sanitizeErrorMessage(i18nMessage),
|
||||
code,
|
||||
}
|
||||
} catch {
|
||||
return { message: error.message }
|
||||
return { message: sanitizeErrorMessage(error.message) }
|
||||
}
|
||||
}
|
||||
|
||||
return { message: error.message }
|
||||
return { message: sanitizeErrorMessage(error.message) }
|
||||
}
|
||||
|
||||
export const getFetchErrorMessage = (error: Error) => {
|
||||
|
|
@ -58,7 +59,7 @@ export const createErrorToaster = (title?: string) => (err: Error) =>
|
|||
toastFetchError(err, { title })
|
||||
|
||||
export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => {
|
||||
const { message: fallbackMessage } = error
|
||||
const fallbackMessage = sanitizeErrorMessage(error.message)
|
||||
let message = fallbackMessage
|
||||
let _reason = ""
|
||||
let code: number | undefined
|
||||
|
|
@ -113,6 +114,8 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
|
|||
}
|
||||
}
|
||||
|
||||
message = sanitizeErrorMessage(message)
|
||||
|
||||
// 2fa errors are handled by the form
|
||||
if (code === 4007 || code === 4008) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const PLAN_FEATURE_ORDER: Array<keyof PaymentFeature> = [
|
|||
]
|
||||
|
||||
const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"]
|
||||
const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS"
|
||||
|
||||
type SegmentLayout = {
|
||||
width: number
|
||||
|
|
@ -110,6 +111,14 @@ type ActiveSubscription = {
|
|||
canManage: boolean
|
||||
}
|
||||
|
||||
type BillingPortalResponse = {
|
||||
code: number
|
||||
data?: {
|
||||
url: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
const currencyFormatter = (() => {
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
|
|
@ -425,6 +434,25 @@ export const PlanScreen: NavigationControllerView = () => {
|
|||
return Math.round(total / paidPlans.length)
|
||||
}, [sortedPlans])
|
||||
|
||||
const openStripeBillingPortal = useCallback(async () => {
|
||||
const data = await followClient.request<BillingPortalResponse>("/billing/portal", {
|
||||
method: "POST",
|
||||
body: { returnUrl: proxyEnv.WEB_URL },
|
||||
})
|
||||
if (data.code !== 0 || !data.data?.url) {
|
||||
throw new Error(data.message || t("subscription.actions.manage_error"))
|
||||
}
|
||||
|
||||
await openURL(data.data.url)
|
||||
}, [t])
|
||||
|
||||
const billingPortalMutation = useMutation({
|
||||
mutationFn: openStripeBillingPortal,
|
||||
onError: () => {
|
||||
toast.error(t("subscription.actions.manage_error"))
|
||||
},
|
||||
})
|
||||
|
||||
const upgradeMutation = useMutation<void, Error, UpgradeVariables>({
|
||||
mutationFn: async ({ planId, annual }) => {
|
||||
const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId)
|
||||
|
|
@ -452,6 +480,13 @@ export const PlanScreen: NavigationControllerView = () => {
|
|||
cancelUrl: proxyEnv.WEB_URL,
|
||||
disableRedirect: true,
|
||||
})
|
||||
if (response.error?.code === ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE) {
|
||||
await openStripeBillingPortal()
|
||||
return
|
||||
}
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
|
||||
const redirectUrl =
|
||||
typeof response === "object" && response && "data" in response && response.data
|
||||
|
|
@ -468,24 +503,6 @@ export const PlanScreen: NavigationControllerView = () => {
|
|||
},
|
||||
})
|
||||
|
||||
const billingPortalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const data = await followClient.request<{ code: number; data?: { url: string } }>(
|
||||
"/billing/portal",
|
||||
{
|
||||
method: "POST",
|
||||
body: { returnUrl: proxyEnv.WEB_URL },
|
||||
},
|
||||
)
|
||||
if (data.code === 0 && data.data?.url) {
|
||||
await openURL(data.data.url)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("subscription.actions.manage_error"))
|
||||
},
|
||||
})
|
||||
|
||||
const handleManageSubscription = useCallback(() => {
|
||||
billingPortalMutation.mutate()
|
||||
}, [billingPortalMutation])
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ import { proxyEnv } from "@/src/lib/proxy-env"
|
|||
import { queryClient } from "@/src/lib/query-client"
|
||||
import { toast } from "@/src/lib/toast"
|
||||
|
||||
import {
|
||||
buildAppleVerificationRequest,
|
||||
isKnownAppleSubscriptionPurchase,
|
||||
selectSignedTransactionInfo,
|
||||
} from "./apple-iap-purchase"
|
||||
|
||||
const billingSubscriptionQueryKey = ["billingSubscription"]
|
||||
|
||||
type BillingSubscriptionResponse = {
|
||||
|
|
@ -123,16 +129,23 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
|||
|
||||
const verifyPurchase = useCallback(
|
||||
async (purchase: Purchase) => {
|
||||
const productId = purchase.id
|
||||
const jwsRepresentation =
|
||||
purchase.purchaseToken ||
|
||||
(await getTransactionJwsIOS(productId).catch(() => null)) ||
|
||||
(await validateReceipt({ apple: { sku: productId } })
|
||||
.then((result) => ("jwsRepresentation" in result ? result.jwsRepresentation : undefined))
|
||||
.catch(() => {}))
|
||||
const productId = purchase.productId
|
||||
let signedTransactionInfo = selectSignedTransactionInfo(purchase.purchaseToken)
|
||||
|
||||
if (!jwsRepresentation) {
|
||||
throw new Error(t("subscription.actions.upgrade_error"))
|
||||
if (!signedTransactionInfo) {
|
||||
signedTransactionInfo = selectSignedTransactionInfo(
|
||||
await getTransactionJwsIOS(productId).catch(() => null),
|
||||
)
|
||||
}
|
||||
|
||||
if (!signedTransactionInfo) {
|
||||
signedTransactionInfo = selectSignedTransactionInfo(
|
||||
await validateReceipt({ apple: { sku: productId } })
|
||||
.then((result) =>
|
||||
"jwsRepresentation" in result ? result.jwsRepresentation : undefined,
|
||||
)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
}
|
||||
|
||||
const response = await followClient.request<{
|
||||
|
|
@ -140,23 +153,21 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
|||
data: BillingSubscriptionResponse
|
||||
}>("/billing/apple/verify", {
|
||||
method: "POST",
|
||||
body: {
|
||||
signedTransactionInfo: jwsRepresentation,
|
||||
},
|
||||
body: buildAppleVerificationRequest(purchase, signedTransactionInfo),
|
||||
})
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error("Failed to verify Apple subscription")
|
||||
}
|
||||
},
|
||||
[t, validateReceipt],
|
||||
[validateReceipt],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
Platform.OS !== "ios" ||
|
||||
!currentPurchase ||
|
||||
!knownSubscriptionIds.has(currentPurchase.id)
|
||||
!isKnownAppleSubscriptionPurchase(currentPurchase, knownSubscriptionIds)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
@ -280,7 +291,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
|||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
const restoredPurchases = availablePurchasesRef.current.filter((purchase) =>
|
||||
knownSubscriptionIds.has(purchase.id),
|
||||
isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds),
|
||||
)
|
||||
|
||||
if (restoredPurchases.length === 0) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
buildAppleVerificationRequest,
|
||||
isCompactJws,
|
||||
isKnownAppleSubscriptionPurchase,
|
||||
selectSignedTransactionInfo,
|
||||
} from "./apple-iap-purchase"
|
||||
|
||||
describe("Apple IAP purchase identifiers", () => {
|
||||
it("matches subscriptions by product ID instead of transaction ID", () => {
|
||||
const knownSubscriptionIds = new Set(["is.follow.basic.monthly"])
|
||||
const purchase = {
|
||||
id: "2000001234567890",
|
||||
productId: "is.follow.basic.monthly",
|
||||
}
|
||||
|
||||
expect(isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds)).toBe(true)
|
||||
})
|
||||
|
||||
it("builds verification hints from transaction identifiers", () => {
|
||||
const request = buildAppleVerificationRequest(
|
||||
{
|
||||
id: "2000001234567890",
|
||||
originalTransactionIdentifierIOS: "2000001000000000",
|
||||
productId: "is.follow.basic.monthly",
|
||||
transactionId: "2000001234567890",
|
||||
},
|
||||
"header.payload.signature",
|
||||
)
|
||||
|
||||
expect(request).toEqual({
|
||||
originalTransactionId: "2000001000000000",
|
||||
signedTransactionInfo: "header.payload.signature",
|
||||
transactionId: "2000001234567890",
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to the purchase ID when transactionId is absent", () => {
|
||||
expect(
|
||||
buildAppleVerificationRequest({
|
||||
id: "2000001234567890",
|
||||
productId: "is.follow.basic.monthly",
|
||||
}),
|
||||
).toEqual({
|
||||
originalTransactionId: undefined,
|
||||
signedTransactionInfo: undefined,
|
||||
transactionId: "2000001234567890",
|
||||
})
|
||||
})
|
||||
|
||||
it("does not submit a transaction ID as signed transaction info", () => {
|
||||
expect(isCompactJws("2000001234567890")).toBe(false)
|
||||
expect(selectSignedTransactionInfo("2000001234567890", null, "header.payload.signature")).toBe(
|
||||
"header.payload.signature",
|
||||
)
|
||||
expect(
|
||||
buildAppleVerificationRequest(
|
||||
{
|
||||
id: "2000001234567890",
|
||||
productId: "is.follow.basic.monthly",
|
||||
purchaseToken: "2000001234567890",
|
||||
},
|
||||
"2000001234567890",
|
||||
),
|
||||
).toEqual({
|
||||
originalTransactionId: undefined,
|
||||
signedTransactionInfo: undefined,
|
||||
transactionId: "2000001234567890",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
export type ApplePurchaseIdentity = {
|
||||
id: string
|
||||
originalTransactionIdentifierIOS?: string | null
|
||||
productId: string
|
||||
purchaseToken?: string | null
|
||||
transactionId?: string | null
|
||||
}
|
||||
|
||||
export type AppleVerificationRequest = {
|
||||
originalTransactionId?: string
|
||||
signedTransactionInfo?: string
|
||||
transactionId?: string
|
||||
}
|
||||
|
||||
const compactJwsSegmentPattern = /^[\w-]+$/
|
||||
|
||||
export const isCompactJws = (value?: string | null): value is string => {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const segments = value.split(".")
|
||||
return (
|
||||
segments.length === 3 &&
|
||||
segments.every((segment) => segment.length > 0 && compactJwsSegmentPattern.test(segment))
|
||||
)
|
||||
}
|
||||
|
||||
export const selectSignedTransactionInfo = (...candidates: Array<string | null | undefined>) =>
|
||||
candidates.find(isCompactJws)
|
||||
|
||||
export const isKnownAppleSubscriptionPurchase = (
|
||||
purchase: Pick<ApplePurchaseIdentity, "productId">,
|
||||
knownSubscriptionIds: ReadonlySet<string>,
|
||||
) => knownSubscriptionIds.has(purchase.productId)
|
||||
|
||||
export const buildAppleVerificationRequest = (
|
||||
purchase: ApplePurchaseIdentity,
|
||||
signedTransactionInfo?: string | null,
|
||||
): AppleVerificationRequest => ({
|
||||
originalTransactionId: purchase.originalTransactionIdentifierIOS || undefined,
|
||||
signedTransactionInfo: isCompactJws(signedTransactionInfo) ? signedTransactionInfo : undefined,
|
||||
transactionId: purchase.transactionId || purchase.id || undefined,
|
||||
})
|
||||
|
|
@ -7,7 +7,7 @@ import { m, useAnimationControls } from "motion/react"
|
|||
import { Fragment, useEffect, useState } from "react"
|
||||
import * as React from "react"
|
||||
|
||||
const NotFoundContent = () => {
|
||||
export const NotFoundContent = () => {
|
||||
const [glitchText, setGlitchText] = useState("404")
|
||||
const [isGlitching, setIsGlitching] = useState(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { NotFoundContent } from "@client/components/common/404"
|
||||
import { FeedIcon } from "@client/components/ui/feed-icon"
|
||||
import { openInFollowApp } from "@client/lib/helper"
|
||||
import { UrlBuilder } from "@client/lib/url-builder"
|
||||
|
|
@ -10,6 +11,7 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
|
|||
import { useTitle } from "@follow/hooks"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { SubscriptionWithFeed, UserProfile } from "@follow-app/client-sdk"
|
||||
import { FollowAPIError } from "@follow-app/client-sdk"
|
||||
import * as React from "react"
|
||||
import { Fragment, memo, useState } from "react"
|
||||
import { useParams } from "react-router"
|
||||
|
|
@ -89,22 +91,43 @@ export const Component = () => {
|
|||
|
||||
useTitle(user.data?.name)
|
||||
|
||||
if (user.isLoading) {
|
||||
return <LoadingCircle size="large" className="center fixed inset-0" />
|
||||
}
|
||||
|
||||
if (!user.data) {
|
||||
if (user.error instanceof FollowAPIError && user.error.status === 404) {
|
||||
return <NotFoundContent />
|
||||
}
|
||||
|
||||
return <ProfileLoadError onRetry={() => void user.refetch()} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{user.isLoading ? (
|
||||
<LoadingCircle size="large" className="center fixed inset-0" />
|
||||
) : (
|
||||
<Fragment>
|
||||
<UserHero user={user.data!} />
|
||||
<Lists userId={user.data?.id} />
|
||||
{/* Subscriptions Section */}
|
||||
<Subscriptions userId={user.data?.id} />
|
||||
</Fragment>
|
||||
)}
|
||||
</>
|
||||
<Fragment>
|
||||
<UserHero user={user.data} />
|
||||
<Lists userId={user.data.id} />
|
||||
{/* Subscriptions Section */}
|
||||
<Subscriptions userId={user.data.id} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
const ProfileLoadError = ({ onRetry }: { onRetry: () => void }) => (
|
||||
<div className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-6 text-center">
|
||||
<i className="i-mgc-warning-fill mb-6 size-12 text-orange-500" />
|
||||
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Unable to load this profile
|
||||
</h1>
|
||||
<p className="mt-3 text-zinc-500 dark:text-zinc-400">
|
||||
This may be a temporary problem. Please try again.
|
||||
</p>
|
||||
<Button buttonClassName="mt-8" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UserHero = ({ user }: { user: UserProfile }) => {
|
||||
const subscriptions = useUserSubscriptionsQuery(user.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { followClient } from "@client/lib/api-fetch"
|
|||
import { getProviders } from "@client/lib/auth"
|
||||
import { getHydrateData } from "@client/lib/helper"
|
||||
import type { LoginHydrateData } from "@client/pages/(login)/login/metadata"
|
||||
import { isBizId, sortByAlphabet } from "@follow/utils/utils"
|
||||
import { sortByAlphabet } from "@follow/utils/utils"
|
||||
import type {
|
||||
InboxSubscriptionResponse,
|
||||
ListSubscriptionResponse,
|
||||
|
|
@ -10,6 +10,8 @@ import type {
|
|||
} from "@follow-app/client-sdk"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
import { getUserProfile } from "../../src/lib/user-profile-params"
|
||||
|
||||
type GetUserSubscriptionsResponse = (
|
||||
SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse
|
||||
)[]
|
||||
|
|
@ -69,13 +71,7 @@ export const useUserSubscriptionsQuery = (userId: string | undefined) => {
|
|||
}
|
||||
|
||||
export const fetchUser = async (handleOrId: string | undefined) => {
|
||||
const handle = isBizId(handleOrId || "")
|
||||
? handleOrId
|
||||
: `${handleOrId}`.startsWith("@")
|
||||
? `${handleOrId}`.slice(1)
|
||||
: handleOrId
|
||||
|
||||
const res = await followClient.api.profiles.getProfile({ id: handleOrId, handle })
|
||||
const res = await getUserProfile(followClient, handleOrId)
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
|
||||
"meta": "tsx helper/meta-map.ts --watch",
|
||||
"start": "tsx index.ts",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import { runInNewContext } from "node:vm"
|
||||
|
||||
import { minify } from "html-minifier-terser"
|
||||
import { parseHTML } from "linkedom"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { createHydrationScript, injectHydrationScript } from "./hydration-script"
|
||||
|
||||
describe("hydration script", () => {
|
||||
it("keeps attacker-controlled keys and data inside a single script element", () => {
|
||||
const key = `profile</ScRiPt><script id="key-payload">`
|
||||
const data = {
|
||||
name: `</script><script id="data-payload">globalThis.__pwned__ = true</script><!--`,
|
||||
characters: "<>&\u2028\u2029",
|
||||
}
|
||||
const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>")
|
||||
|
||||
injectHydrationScript(document, key, data)
|
||||
|
||||
const serializedHtml = document.toString()
|
||||
const { document: reparsedDocument } = parseHTML(serializedHtml)
|
||||
const scripts = reparsedDocument.querySelectorAll("script")
|
||||
const scriptSource = scripts[0]?.textContent
|
||||
|
||||
expect(scripts).toHaveLength(1)
|
||||
expect(reparsedDocument.querySelector("#key-payload")).toBeNull()
|
||||
expect(reparsedDocument.querySelector("#data-payload")).toBeNull()
|
||||
expect(scriptSource).not.toContain("<")
|
||||
expect(scriptSource).not.toContain(">")
|
||||
expect(scriptSource).not.toContain("&")
|
||||
expect(scriptSource).not.toContain("\u2028")
|
||||
expect(scriptSource).not.toContain("\u2029")
|
||||
|
||||
const window = {} as {
|
||||
__HYDRATE__?: Record<string, unknown>
|
||||
}
|
||||
runInNewContext(scriptSource!, { window })
|
||||
|
||||
expect(window.__HYDRATE__?.[key]).toEqual(data)
|
||||
})
|
||||
|
||||
it("preserves JSON.parse semantics for __proto__ properties", () => {
|
||||
const data = JSON.parse(`{"__proto__":{"polluted":true}}`)
|
||||
const window = {} as {
|
||||
__HYDRATE__?: Record<string, unknown>
|
||||
}
|
||||
|
||||
runInNewContext(createHydrationScript("profile", data), { window })
|
||||
|
||||
const hydrated = window.__HYDRATE__?.profile as Record<string, unknown>
|
||||
expect(Object.hasOwn(hydrated, "__proto__")).toBe(true)
|
||||
expect((Object.getPrototypeOf(hydrated) as { polluted?: boolean }).polluted).toBeUndefined()
|
||||
expect(({} as { polluted?: boolean }).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
it("defines a __proto__ hydration key without changing the store prototype", () => {
|
||||
const window = {} as {
|
||||
__HYDRATE__?: Record<string, unknown>
|
||||
}
|
||||
|
||||
runInNewContext(createHydrationScript("__proto__", { value: "safe" }), { window })
|
||||
|
||||
const hydrationStore = window.__HYDRATE__!
|
||||
expect(Object.hasOwn(hydrationStore, "__proto__")).toBe(true)
|
||||
expect(Object.getOwnPropertyDescriptor(hydrationStore, "__proto__")?.value).toEqual({
|
||||
value: "safe",
|
||||
})
|
||||
expect((Object.getPrototypeOf(hydrationStore) as { value?: string }).value).toBeUndefined()
|
||||
})
|
||||
|
||||
it("remains safe after production HTML and JavaScript minification", async () => {
|
||||
const key = "profile"
|
||||
const data = {
|
||||
name: `</script><script id="minified-payload">globalThis.__pwned__ = true</script>`,
|
||||
}
|
||||
const { document } = parseHTML("<!doctype html><html><head></head><body></body></html>")
|
||||
injectHydrationScript(document, key, data)
|
||||
|
||||
const minifiedHtml = await minify(document.toString(), {
|
||||
collapseBooleanAttributes: true,
|
||||
collapseInlineTagWhitespace: true,
|
||||
collapseWhitespace: true,
|
||||
html5: true,
|
||||
minifyCSS: true,
|
||||
minifyJS: true,
|
||||
removeComments: true,
|
||||
removeTagWhitespace: true,
|
||||
})
|
||||
const { document: reparsedDocument } = parseHTML(minifiedHtml)
|
||||
const scripts = reparsedDocument.querySelectorAll("script")
|
||||
const scriptSource = scripts[0]?.textContent
|
||||
|
||||
expect(scripts).toHaveLength(1)
|
||||
expect(reparsedDocument.querySelector("#minified-payload")).toBeNull()
|
||||
expect(scriptSource?.toLowerCase()).not.toContain("</script")
|
||||
|
||||
const window = {} as {
|
||||
__HYDRATE__?: Record<string, unknown>
|
||||
}
|
||||
runInNewContext(scriptSource!, { window })
|
||||
expect(window.__HYDRATE__?.[key]).toEqual(data)
|
||||
})
|
||||
|
||||
it("rejects top-level values that JSON cannot serialize", () => {
|
||||
expect(() => createHydrationScript("profile", undefined)).toThrow(
|
||||
"Hydration data must be JSON serializable",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
const serializeJsonForInlineScript = (value: unknown): string => {
|
||||
const serialized = JSON.stringify(value)
|
||||
|
||||
if (serialized === undefined) {
|
||||
throw new TypeError("Hydration data must be JSON serializable")
|
||||
}
|
||||
|
||||
return serialized
|
||||
.replaceAll("<", "\\u003c")
|
||||
.replaceAll(">", "\\u003e")
|
||||
.replaceAll("&", "\\u0026")
|
||||
.replaceAll("\u2028", "\\u2028")
|
||||
.replaceAll("\u2029", "\\u2029")
|
||||
}
|
||||
|
||||
export const createHydrationScript = (key: string, data: unknown): string => {
|
||||
const serializedData = JSON.stringify(data)
|
||||
|
||||
if (serializedData === undefined) {
|
||||
throw new TypeError("Hydration data must be JSON serializable")
|
||||
}
|
||||
|
||||
return `
|
||||
window.__HYDRATE__ = window.__HYDRATE__ || {}
|
||||
Object.defineProperty(window.__HYDRATE__, ${serializeJsonForInlineScript(key)}, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: JSON.parse(${serializeJsonForInlineScript(serializedData)}),
|
||||
writable: true,
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
export const injectHydrationScript = (document: Document, key: string, data: unknown): void => {
|
||||
const script = document.createElement("script")
|
||||
script.textContent = createHydrationScript(key, data)
|
||||
document.head.append(script)
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { FollowClient } from "@follow-app/client-sdk"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { getUserProfile, resolveUserProfileParams } from "./user-profile-params"
|
||||
|
||||
vi.mock("@follow/utils/utils", () => ({
|
||||
isBizId: (value: string | undefined) => value === "41125409313095680",
|
||||
}))
|
||||
|
||||
describe("resolveUserProfileParams", () => {
|
||||
it("uses a handle without also sending it as an id", () => {
|
||||
expect(resolveUserProfileParams("DIYgod")).toEqual({
|
||||
id: undefined,
|
||||
handle: "DIYgod",
|
||||
})
|
||||
})
|
||||
|
||||
it("removes a leading at sign from handles", () => {
|
||||
expect(resolveUserProfileParams("@DIYgod")).toEqual({
|
||||
id: undefined,
|
||||
handle: "DIYgod",
|
||||
})
|
||||
})
|
||||
|
||||
it("uses a business id without also sending it as a handle", () => {
|
||||
expect(resolveUserProfileParams("41125409313095680")).toEqual({
|
||||
id: "41125409313095680",
|
||||
handle: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("uses the resolved parameters for the profile request", async () => {
|
||||
const getProfile = vi.fn().mockResolvedValue({ data: { id: "profile-id" } })
|
||||
const apiClient = {
|
||||
api: {
|
||||
profiles: {
|
||||
getProfile,
|
||||
},
|
||||
},
|
||||
} as unknown as FollowClient
|
||||
|
||||
await getUserProfile(apiClient, "DIYgod")
|
||||
|
||||
expect(getProfile).toHaveBeenCalledOnce()
|
||||
expect(getProfile).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
handle: "DIYgod",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { isBizId } from "@follow/utils/utils"
|
||||
import type { FollowClient } from "@follow-app/client-sdk"
|
||||
|
||||
export const resolveUserProfileParams = (handleOrId: string | undefined) => {
|
||||
if (isBizId(handleOrId || "")) {
|
||||
return {
|
||||
id: handleOrId,
|
||||
handle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: undefined,
|
||||
handle: handleOrId?.startsWith("@") ? handleOrId.slice(1) : handleOrId,
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserProfile = (apiClient: FollowClient, handleOrId: string | undefined) =>
|
||||
apiClient.api.profiles.getProfile(resolveUserProfileParams(handleOrId))
|
||||
|
|
@ -9,6 +9,7 @@ import { FetchError } from "ofetch"
|
|||
import path, { dirname, resolve } from "pathe"
|
||||
import xss from "xss"
|
||||
|
||||
import { injectHydrationScript } from "../lib/hydration-script"
|
||||
import { NotFoundError } from "../lib/not-found"
|
||||
import { buildSeoMetaTags } from "../lib/seo"
|
||||
import { injectMetaHandler, MetaError } from "../meta-handler"
|
||||
|
|
@ -151,13 +152,7 @@ async function injectMetaToTemplate(document: Document, req: FastifyRequest, res
|
|||
break
|
||||
}
|
||||
case "hydrate": {
|
||||
// Insert hydrate script
|
||||
const script = document.createElement("script")
|
||||
script.innerHTML = `
|
||||
window.__HYDRATE__ = window.__HYDRATE__ || {}
|
||||
window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})
|
||||
`
|
||||
document.head.append(script)
|
||||
injectHydrationScript(document, meta.key, meta.data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ export const ogRoute = (app: FastifyInstance) => {
|
|||
|
||||
const createErrorFallback = (reply: FastifyReply) => (code: number | Error) => {
|
||||
if (typeof code !== "number" && code instanceof Error) {
|
||||
reply.code(500).send(code.message)
|
||||
console.error("OG render error:", code)
|
||||
reply.code(500).send("Internal server error")
|
||||
return null
|
||||
}
|
||||
let message = "Internal server error"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
import { isBizId } from "@follow/utils/utils"
|
||||
import type { FollowClient } from "@follow-app/client-sdk"
|
||||
import * as React from "react"
|
||||
|
||||
import { renderToImage } from "../../lib/og/render-to-image"
|
||||
import { getUserProfile } from "../../lib/user-profile-params"
|
||||
import { getImageBase64, OGAvatar, OGCanvas } from "./__base"
|
||||
|
||||
export const renderUserOG = async (apiClient: FollowClient, id: string) => {
|
||||
const handle = isBizId(id || "") ? id : `${id}`.startsWith("@") ? `${id}`.slice(1) : id
|
||||
|
||||
const user = await apiClient.api.profiles.getProfile({
|
||||
id,
|
||||
handle,
|
||||
})
|
||||
export const renderUserOG = async (apiClient: FollowClient, handleOrId: string) => {
|
||||
const user = await getUserProfile(apiClient, handleOrId)
|
||||
|
||||
if (!user) {
|
||||
throw 404
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"./client/**/*.tsx",
|
||||
"./types/**/*.d.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"./tailwind.config.ts",
|
||||
"./helper/**/*.ts"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { defineProject } from "vitest/config"
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
})
|
||||
|
|
@ -12,6 +12,7 @@ import xss from "xss"
|
|||
import resvgWasm from "./resvg.wasm"
|
||||
// OG image rendering
|
||||
import { createFollowClient } from "./src/lib/api-client"
|
||||
import { injectHydrationScript } from "./src/lib/hydration-script"
|
||||
import { NotFoundError } from "./src/lib/not-found"
|
||||
import { setFontsBucket } from "./src/lib/og/fonts.worker"
|
||||
import { setWasmModule } from "./src/lib/og/resvg-wasm-shim"
|
||||
|
|
@ -127,7 +128,7 @@ app.get("/og/:type/:id", async (c) => {
|
|||
return c.text(e === 404 ? "Not found" : "Internal server error", e)
|
||||
}
|
||||
console.error("OG render error:", e)
|
||||
return c.text(e?.message || "Internal server error", 500)
|
||||
return c.text("Internal server error", 500)
|
||||
}
|
||||
|
||||
if (!imageRes) {
|
||||
|
|
@ -298,12 +299,7 @@ async function injectMetaToTemplate(document: Document, c: any) {
|
|||
break
|
||||
}
|
||||
case "hydrate": {
|
||||
const script = document.createElement("script")
|
||||
script.innerHTML = `
|
||||
window.__HYDRATE__ = window.__HYDRATE__ || {}
|
||||
window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})
|
||||
`
|
||||
document.head.append(script)
|
||||
injectHydrationScript(document, meta.key, meta.data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
"name": "folo-ssr",
|
||||
"main": "dist/worker/worker-entry.mjs",
|
||||
"compatibility_date": "2026-02-01",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
// The SSR Worker fetches api.folo.is, another Worker Route in the same zone.
|
||||
"global_fetch_strictly_public",
|
||||
],
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "Feed failed to parse",
|
||||
"2010": "Ownership challenge failed",
|
||||
"2011": "Subscription limit exceeded",
|
||||
"2012": "RSSHub feed subscription limit exceeded",
|
||||
"3000": "Entry not found",
|
||||
"4000": "Already claimed",
|
||||
"4001": "User wallet error",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "Échec de l'analyse du flux",
|
||||
"2010": "Échec de la vérification de propriété",
|
||||
"2011": "Limite d'abonnement dépassée",
|
||||
"2012": "Limite d'abonnement aux flux RSSHub dépassée",
|
||||
"3000": "Entrée introuvable",
|
||||
"4000": "Déjà réclamé",
|
||||
"4001": "Erreur de portefeuille utilisateur",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "フィードの解析に失敗しました",
|
||||
"2010": "所有権の確認に失敗しました",
|
||||
"2011": "購読制限を超えました",
|
||||
"2012": "RSSHubフィードの購読数制限を超えました",
|
||||
"3000": "エントリが見つかりません",
|
||||
"4000": "すでに請求されています",
|
||||
"4001": "ユーザーウォレットエラー",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "订阅源解析失败",
|
||||
"2010": "所有权挑战失败",
|
||||
"2011": "超出订阅限制",
|
||||
"2012": "RSSHub 订阅源数量限制已超出",
|
||||
"3000": "未找到条目",
|
||||
"4000": "已被认领",
|
||||
"4001": "用户钱包错误",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"2004": "訂閱源解析失敗",
|
||||
"2010": "所有權挑戰失敗",
|
||||
"2011": "超過訂閱限制",
|
||||
"2012": "RSSHub 訂閱源數量限制已超過",
|
||||
"3000": "條目未找到",
|
||||
"4000": "已被認領",
|
||||
"4001": "用戶錢包錯誤",
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@
|
|||
}
|
||||
|
||||
[data-theme="dark"] .kbd {
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border: 1px solid hsl(var(--fo-border) / 0.5);
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.12),
|
||||
0 1px 4px rgba(0, 0, 0, 0.08),
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ const twConfig = {
|
|||
},
|
||||
|
||||
colors: {
|
||||
border: "hsl(var(--border) / <alpha-value>)",
|
||||
// DaisyUI 5 reserves --border for border width, so keep the color token namespaced.
|
||||
border: "hsl(var(--fo-border) / <alpha-value>)",
|
||||
background: "hsl(var(--background) / <alpha-value>)",
|
||||
|
||||
accent: "hsl(var(--fo-a) / <alpha-value>)",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
--background: 0 0% 100%;
|
||||
--color-background: 255 255 255;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--fo-border: 20 5.9% 90%;
|
||||
--border: var(--fo-border);
|
||||
--radius: 0.5rem;
|
||||
|
||||
--fo-selection-active: theme(colors.accent/90);
|
||||
|
|
@ -39,7 +40,8 @@
|
|||
|
||||
--background: 0 0% 7.1%;
|
||||
--color-background: 18 18 18;
|
||||
--border: 0 0% 22.1%;
|
||||
--fo-border: 0 0% 22.1%;
|
||||
--border: var(--fo-border);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
--background: 0 0% 100%;
|
||||
--color-background: 255 255 255;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--fo-border: 20 5.9% 90%;
|
||||
--border: var(--fo-border);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +19,8 @@
|
|||
--background: 0 0% 7.1%;
|
||||
--color-background: 18 18 18;
|
||||
|
||||
--border: 0 0% 22.1%;
|
||||
--fo-border: 0 0% 22.1%;
|
||||
--border: var(--fo-border);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1398,8 +1398,8 @@ importers:
|
|||
specifier: 4.1.2
|
||||
version: 4.1.2(patch_hash=dc51df1dccb62feafc2369115ba8e02dba9b261d9cf397f4db6c57d6b4acefe5)(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)
|
||||
react-native-uikit-colors:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3)))
|
||||
specifier: 0.6.2
|
||||
version: 0.6.2(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3)))
|
||||
react-native-volume-manager:
|
||||
specifier: 2.0.8
|
||||
version: 2.0.8(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)
|
||||
|
|
@ -10314,6 +10314,9 @@ packages:
|
|||
os: [darwin]
|
||||
hasBin: true
|
||||
|
||||
apple-uikit-colors@0.6.2:
|
||||
resolution: {integrity: sha512-sv0b92krbTZGNt4AJQLAPioUvuEI/wW2cLqGuEBWl3KpU2JK/odOftx8+dGSC5jGWhdB5qnwBqF96X2FA8FUQA==}
|
||||
|
||||
apple-uikit-colors@1.0.0:
|
||||
resolution: {integrity: sha512-G2Ti2ogMOOC1phfHacSrLacDiE0RcLI7IG3aNoaw7Ack23WI8/9QczmUuhxxkU9BoIR2euCZ2G4lD5qJ+OknwA==}
|
||||
|
||||
|
|
@ -16811,6 +16814,14 @@ packages:
|
|||
shaka-player:
|
||||
optional: true
|
||||
|
||||
react-native-uikit-colors@0.6.2:
|
||||
resolution: {integrity: sha512-qcsQOb/0+3eoYPS/pJG8R5Idxnk+o/UKPwTfa4wCxJ/gFJEcShm1q0wsw9D7BgceXo8tK+aMDebeguvNJYt6Og==}
|
||||
peerDependencies:
|
||||
nativewind: '>=4.1.0'
|
||||
react: 19.2.7
|
||||
react-native: '>=0.76.0'
|
||||
tailwindcss: '>=3.0.0'
|
||||
|
||||
react-native-uikit-colors@1.0.0:
|
||||
resolution: {integrity: sha512-DEEc/OTrNNkIgfgbjSsG4pL0pvBaqtwmjN4FCITHQYeJFupxPOcGJehatMfPE6x3QItyIe45C0sYZz/ish+x6w==}
|
||||
peerDependencies:
|
||||
|
|
@ -17902,6 +17913,9 @@ packages:
|
|||
peerDependencies:
|
||||
tailwindcss: ^4.0.0
|
||||
|
||||
tailwindcss-uikit-colors@0.6.2:
|
||||
resolution: {integrity: sha512-RfF3VVB2nHvJwPZ2ZSTMgKYbZjRny/KW3rP6ufg6/bopxINDQ9TpN/fJXxvKnD86hIU0F2oLIfAv2r6o/ZWcZw==}
|
||||
|
||||
tailwindcss-uikit-colors@1.0.0:
|
||||
resolution: {integrity: sha512-18MGdMVSoXFKjcVkUIu7Q5USqdenAvgFJT9DEocPmdS94b8d/z3s0Uw8LvaNDINfDN9ls24hfg6/hyFqpcZpxw==}
|
||||
|
||||
|
|
@ -29534,6 +29548,8 @@ snapshots:
|
|||
repeat-string: 1.6.1
|
||||
optional: true
|
||||
|
||||
apple-uikit-colors@0.6.2: {}
|
||||
|
||||
apple-uikit-colors@1.0.0: {}
|
||||
|
||||
archiver-utils@2.1.0:
|
||||
|
|
@ -37499,6 +37515,15 @@ snapshots:
|
|||
react: 19.2.7
|
||||
react-native: 0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)
|
||||
|
||||
react-native-uikit-colors@0.6.2(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))):
|
||||
dependencies:
|
||||
apple-uikit-colors: 0.6.2
|
||||
nativewind: 4.2.6(ea162fae5d41964a07e45415df8f37cb)
|
||||
react: 19.2.7
|
||||
react-native: 0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7)
|
||||
tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))
|
||||
tailwindcss-uikit-colors: 0.6.2
|
||||
|
||||
react-native-uikit-colors@1.0.0(nativewind@4.2.6(ea162fae5d41964a07e45415df8f37cb))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))):
|
||||
dependencies:
|
||||
apple-uikit-colors: 1.0.0
|
||||
|
|
@ -38886,6 +38911,10 @@ snapshots:
|
|||
dependencies:
|
||||
tailwindcss: 4.3.2
|
||||
|
||||
tailwindcss-uikit-colors@0.6.2:
|
||||
dependencies:
|
||||
apple-uikit-colors: 0.6.2
|
||||
|
||||
tailwindcss-uikit-colors@1.0.0:
|
||||
dependencies:
|
||||
apple-uikit-colors: 1.0.0
|
||||
|
|
|
|||
Loading…
Reference in New Issue