Compare commits
No commits in common. "dev" and "desktop@1.11.0" have entirely different histories.
dev
...
desktop@1.
|
|
@ -1,37 +0,0 @@
|
||||||
# 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
|
|
||||||
'''
|
|
||||||
|
|
@ -7,21 +7,13 @@
|
||||||
|
|
||||||
import { execSync } from "node:child_process"
|
import { execSync } from "node:child_process"
|
||||||
import { appendFileSync } from "node:fs"
|
import { appendFileSync } from "node:fs"
|
||||||
import { pathToFileURL } from "node:url"
|
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const RELEASE_PATTERNS = {
|
const RELEASE_PATTERNS = {
|
||||||
desktop: /^release\(desktop\): Release (v\d+\.\d+\.\d+(?:-[0-9A-Z-.]+)?)(?: \(#\d+\))?$/i,
|
desktop: /release\(desktop\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i,
|
||||||
mobile: /^release\(mobile\): Release (v\d+\.\d+\.\d+(?:-[0-9A-Z-.]+)?)(?: \(#\d+\))?$/i,
|
mobile: /release\(mobile\): Release (v\d+\.\d+\.\d+(-[0-9A-Z-.]+)?)/i,
|
||||||
}
|
}
|
||||||
|
|
||||||
const RELEASE_PLATFORM_BY_REF = {
|
|
||||||
main: "desktop",
|
|
||||||
"mobile-main": "mobile",
|
|
||||||
}
|
|
||||||
|
|
||||||
const GITHUB_MERGE_SUBJECT_PATTERN = /^Merge pull request #\d+ from /i
|
|
||||||
|
|
||||||
const EXIT_CODES = {
|
const EXIT_CODES = {
|
||||||
SUCCESS: 0,
|
SUCCESS: 0,
|
||||||
GIT_ERROR: 2,
|
GIT_ERROR: 2,
|
||||||
|
|
@ -65,6 +57,7 @@ function setGitHubOutput(key, value) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the latest commit message
|
* Get the latest commit message
|
||||||
|
* @returns {string} Latest commit message
|
||||||
*/
|
*/
|
||||||
function getLatestCommitMessage() {
|
function getLatestCommitMessage() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -76,45 +69,21 @@ function getLatestCommitMessage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract release information from a commit message.
|
* Extract release information from commit message
|
||||||
* Prefer the subject. For a standard GitHub merge commit, fall back only to the first non-empty body
|
|
||||||
* line, where GitHub stores the PR title. When a GitHub ref is available, only the platform released
|
|
||||||
* from that branch is considered. Other body lines are ignored so stale release commits cannot
|
|
||||||
* retrigger a release.
|
|
||||||
* @param {string} commitMessage - Git commit message
|
* @param {string} commitMessage - Git commit message
|
||||||
* @param {string|undefined} refName - GitHub ref name
|
* @returns {Object|null} Release information or null if no release found
|
||||||
* @returns {{platform: string, version: string, tagName: string}|null} Release information or null
|
|
||||||
*/
|
*/
|
||||||
export function extractReleaseInfo(commitMessage, refName = process.env.GITHUB_REF_NAME) {
|
function extractReleaseInfo(commitMessage) {
|
||||||
const [commitSubject = "", ...commitBodyLines] = commitMessage.split(/\r?\n/)
|
for (const [platform, regex] of Object.entries(RELEASE_PATTERNS)) {
|
||||||
const expectedPlatform = refName ? RELEASE_PLATFORM_BY_REF[refName] : undefined
|
const match = commitMessage.match(regex)
|
||||||
|
if (match) {
|
||||||
|
const version = match[1]
|
||||||
|
const tagName = `${platform}/${version}`
|
||||||
|
|
||||||
if (refName && !expectedPlatform) {
|
return {
|
||||||
return null
|
platform,
|
||||||
}
|
version,
|
||||||
|
tagName,
|
||||||
const platforms = expectedPlatform ? [expectedPlatform] : Object.keys(RELEASE_PATTERNS)
|
|
||||||
const candidates = [commitSubject.trim()]
|
|
||||||
|
|
||||||
if (GITHUB_MERGE_SUBJECT_PATTERN.test(commitSubject)) {
|
|
||||||
const pullRequestTitle = commitBodyLines.map((line) => line.trim()).find(Boolean)
|
|
||||||
if (pullRequestTitle) {
|
|
||||||
candidates.push(pullRequestTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
for (const platform of platforms) {
|
|
||||||
const match = candidate.match(RELEASE_PATTERNS[platform])
|
|
||||||
if (match) {
|
|
||||||
const version = match[1]
|
|
||||||
const tagName = `${platform}/${version}`
|
|
||||||
|
|
||||||
return {
|
|
||||||
platform,
|
|
||||||
version,
|
|
||||||
tagName,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +128,4 @@ function main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
main()
|
||||||
main()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
import { execFile } from "node:child_process"
|
|
||||||
import { mkdtemp, readFile, rm } from "node:fs/promises"
|
|
||||||
import { tmpdir } from "node:os"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
import { promisify } from "node:util"
|
|
||||||
|
|
||||||
import { join } from "pathe"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
import { extractReleaseInfo } from "./extract-release-info.mjs"
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
|
||||||
|
|
||||||
describe("extractReleaseInfo", () => {
|
|
||||||
it("recognizes a mobile release from the subject and ignores old desktop markers in the body", () => {
|
|
||||||
const commitMessage = [
|
|
||||||
"release(mobile): Release v0.5.7 (#5061)",
|
|
||||||
"",
|
|
||||||
"* release(desktop): release v1.11.0",
|
|
||||||
"* docs(mobile): restore desktop release inputs",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
expect(extractReleaseInfo(commitMessage, "mobile-main")).toEqual({
|
|
||||||
platform: "mobile",
|
|
||||||
version: "v0.5.7",
|
|
||||||
tagName: "mobile/v0.5.7",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects a release subject on the wrong target branch", () => {
|
|
||||||
expect(extractReleaseInfo("release(mobile): Release v0.5.7", "main")).toBeNull()
|
|
||||||
expect(extractReleaseInfo("release(desktop): Release v1.12.0", "mobile-main")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recognizes the target platform release from a standard merge commit body", () => {
|
|
||||||
const commitMessage = [
|
|
||||||
"Merge pull request #5061 from RSSNext/release/mobile/0.5.7",
|
|
||||||
"",
|
|
||||||
"release(mobile): Release v0.5.7",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
expect(extractReleaseInfo(commitMessage, "mobile-main")).toEqual({
|
|
||||||
platform: "mobile",
|
|
||||||
version: "v0.5.7",
|
|
||||||
tagName: "mobile/v0.5.7",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("ignores ordinary commits", () => {
|
|
||||||
expect(extractReleaseInfo("fix(mobile): restore release metadata", "mobile-main")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("ignores release markers for the wrong platform in a merge commit body", () => {
|
|
||||||
const commitMessage = [
|
|
||||||
"Merge pull request #5061 from RSSNext/release/mobile/0.5.7",
|
|
||||||
"",
|
|
||||||
"release(mobile): Release v0.5.7",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
expect(extractReleaseInfo(commitMessage, "main")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("ignores stale release markers later in an ordinary commit body", () => {
|
|
||||||
const commitMessage = [
|
|
||||||
"chore(sync): merge mobile-main into dev",
|
|
||||||
"",
|
|
||||||
"* release(mobile): Release v0.5.6",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
expect(extractReleaseInfo(commitMessage, "mobile-main")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not use a stale body marker when another platform release is the subject", () => {
|
|
||||||
const commitMessage = [
|
|
||||||
"release(desktop): Release v1.6.0",
|
|
||||||
"",
|
|
||||||
"* release(mobile): Release v0.4.1",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
expect(extractReleaseInfo(commitMessage, "mobile-main")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("recognizes a desktop release on main", () => {
|
|
||||||
expect(extractReleaseInfo("release(desktop): Release v1.12.0", "main")).toEqual({
|
|
||||||
platform: "desktop",
|
|
||||||
version: "v1.12.0",
|
|
||||||
tagName: "desktop/v1.12.0",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("falls back to subject-based platform detection without a GitHub ref", () => {
|
|
||||||
const originalRefName = process.env.GITHUB_REF_NAME
|
|
||||||
delete process.env.GITHUB_REF_NAME
|
|
||||||
|
|
||||||
try {
|
|
||||||
expect(extractReleaseInfo("release(mobile): Release v0.5.7")).toEqual({
|
|
||||||
platform: "mobile",
|
|
||||||
version: "v0.5.7",
|
|
||||||
tagName: "mobile/v0.5.7",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
if (originalRefName === undefined) {
|
|
||||||
delete process.env.GITHUB_REF_NAME
|
|
||||||
} else {
|
|
||||||
process.env.GITHUB_REF_NAME = originalRefName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("writes the existing GitHub environment and output values from the latest subject", async () => {
|
|
||||||
const repositoryDir = await mkdtemp(join(tmpdir(), "extract-release-info-"))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const githubEnvPath = join(repositoryDir, "github-env.txt")
|
|
||||||
const githubOutputPath = join(repositoryDir, "github-output.txt")
|
|
||||||
const scriptPath = fileURLToPath(new URL("./extract-release-info.mjs", import.meta.url))
|
|
||||||
|
|
||||||
await execFileAsync("git", ["init"], { cwd: repositoryDir })
|
|
||||||
await execFileAsync("git", ["config", "user.name", "Release Test"], {
|
|
||||||
cwd: repositoryDir,
|
|
||||||
})
|
|
||||||
await execFileAsync("git", ["config", "user.email", "release-test@example.com"], {
|
|
||||||
cwd: repositoryDir,
|
|
||||||
})
|
|
||||||
await execFileAsync(
|
|
||||||
"git",
|
|
||||||
[
|
|
||||||
"commit",
|
|
||||||
"--allow-empty",
|
|
||||||
"-m",
|
|
||||||
"release(mobile): Release v0.5.7 (#5061)",
|
|
||||||
"-m",
|
|
||||||
"* release(desktop): release v1.11.0",
|
|
||||||
],
|
|
||||||
{ cwd: repositoryDir },
|
|
||||||
)
|
|
||||||
|
|
||||||
await execFileAsync("node", [scriptPath], {
|
|
||||||
cwd: repositoryDir,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
GITHUB_ENV: githubEnvPath,
|
|
||||||
GITHUB_OUTPUT: githubOutputPath,
|
|
||||||
GITHUB_REF_NAME: "mobile-main",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const githubEnv = await readFile(githubEnvPath, "utf8")
|
|
||||||
const githubOutput = await readFile(githubOutputPath, "utf8")
|
|
||||||
|
|
||||||
expect(githubEnv).toContain("tag_version=mobile/v0.5.7")
|
|
||||||
expect(githubEnv).toContain("platform=mobile")
|
|
||||||
expect(githubEnv).toContain("version=v0.5.7")
|
|
||||||
expect(githubOutput).toContain("tag_version=mobile/v0.5.7")
|
|
||||||
expect(githubOutput).toContain("platform=mobile")
|
|
||||||
expect(githubOutput).toContain("version=v0.5.7")
|
|
||||||
} finally {
|
|
||||||
await rm(repositoryDir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: 🏗 Setup Node.js
|
- name: 🏗 Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
@ -407,7 +407,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: 🏗 Setup Node.js
|
- name: 🏗 Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
@ -148,7 +148,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: 🏗 Setup Node.js
|
- name: 🏗 Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: 🏗 Setup Node.js
|
- name: 🏗 Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ jobs:
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Use Node.js LTS
|
- name: Use Node.js LTS
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ jobs:
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Use Node.js LTS
|
- name: Use Node.js LTS
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Use Node.js LTS
|
- name: Use Node.js LTS
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ jobs:
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
@ -52,10 +52,5 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
export NODE_OPTIONS="--max_old_space_size=16384"
|
export NODE_OPTIONS="--max_old_space_size=16384"
|
||||||
npm exec turbo run format:check typecheck lint
|
npm exec turbo run format:check typecheck lint
|
||||||
- name: Run release workflow tests
|
|
||||||
run: >-
|
|
||||||
pnpm exec vitest run
|
|
||||||
.github/scripts/extract-release-info.test.ts
|
|
||||||
.github/scripts/release-workflow-guards.test.ts
|
|
||||||
- name: Run test
|
- name: Run test
|
||||||
run: npm exec turbo run test
|
run: npm exec turbo run test
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: lts/*
|
||||||
|
|
||||||
|
|
@ -36,6 +36,7 @@ jobs:
|
||||||
- name: Extract release information
|
- name: Extract release information
|
||||||
id: extract_info
|
id: extract_info
|
||||||
run: .github/scripts/extract-release-info.mjs
|
run: .github/scripts/extract-release-info.mjs
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Expose release outputs
|
- name: Expose release outputs
|
||||||
id: release_info
|
id: release_info
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,8 @@ apps/desktop/resources/cli
|
||||||
|
|
||||||
.wrangler
|
.wrangler
|
||||||
|
|
||||||
# Local agent artifacts, except the shared Codex environment
|
# Local agent artifacts
|
||||||
.codex/*
|
.codex/
|
||||||
!.codex/environments/
|
|
||||||
.codex/environments/*
|
|
||||||
!.codex/environments/environment.toml
|
|
||||||
|
|
||||||
# E2E outputs
|
# E2E outputs
|
||||||
/apps/desktop/e2e/playwright-report/
|
/apps/desktop/e2e/playwright-report/
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# Files copied by Codex into managed worktrees
|
|
||||||
apps/desktop/.env
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# What's new in v1.12.0
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
- Hardened custom URL integrations so only trusted protocols can be opened
|
|
||||||
- Improved the error shown when an RSSHub subscription limit is reached
|
|
||||||
|
|
||||||
## No longer broken
|
|
||||||
|
|
||||||
- Fixed share popovers remaining open after completing an action
|
|
||||||
- Fixed plan upgrades for past-due Stripe subscriptions
|
|
||||||
- Restored category chevron rotation
|
|
||||||
- Restored correct border styling across the app
|
|
||||||
|
|
||||||
## Thanks
|
|
||||||
|
|
||||||
Special thanks to volunteer contributor @sebastionoss for hardening custom URL integrations
|
|
||||||
|
|
@ -36,43 +36,7 @@ const ymlMapsMap = {
|
||||||
win32: "latest.yml",
|
win32: "latest.yml",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep external runtime modules and their production dependency trees in app.asar.
|
const keepModules = new Set(["font-list", "vscode-languagedetection"])
|
||||||
// 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"])
|
const keepLanguages = new Set(["en", "en_GB", "en-US", "en_US"])
|
||||||
|
|
||||||
// remove folders & files not to be included in the app
|
// remove folders & files not to be included in the app
|
||||||
|
|
@ -129,10 +93,7 @@ async function cleanSources(buildPath, _electronVersion, platform, _arch, callba
|
||||||
|
|
||||||
const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) => callback()
|
const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) => callback()
|
||||||
|
|
||||||
const keepModulePattern = [...keepModules]
|
const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")})`)
|
||||||
.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
||||||
.join("|")
|
|
||||||
const ignorePattern = new RegExp(`^/node_modules/(?!(?:${keepModulePattern})(?:/|$))`)
|
|
||||||
|
|
||||||
const config: ForgeConfig = {
|
const config: ForgeConfig = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import fsp from "node:fs/promises"
|
import fsp from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
|
|
||||||
import { shell } from "electron"
|
|
||||||
import path from "pathe"
|
import path from "pathe"
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
import { IntegrationService } from "./integration"
|
import { IntegrationService } from "./integration"
|
||||||
|
|
||||||
|
|
@ -70,70 +69,4 @@ describe("IntegrationService", () => {
|
||||||
fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1")),
|
fsp.stat(path.join(vaultPath, "KAWA DESIGN 少女前线2:追放 索米·雪兔献礼 1")),
|
||||||
).rejects.toThrow()
|
).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,29 +81,6 @@ 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 {
|
export class IntegrationService extends IpcService {
|
||||||
static override readonly groupName = "integration"
|
static override readonly groupName = "integration"
|
||||||
|
|
||||||
|
|
@ -405,32 +382,11 @@ ${content}
|
||||||
const requestId = Math.random().toString(36).slice(2, 8)
|
const requestId = Math.random().toString(36).slice(2, 8)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse and validate the protocol up-front. `shell.openExternal` will
|
// Validate URL scheme format
|
||||||
// happily dispatch any scheme the OS has registered a handler for,
|
if (!scheme.includes("://")) {
|
||||||
// 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://')")
|
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)
|
// Log URL scheme execution (mask sensitive data)
|
||||||
const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) =>
|
const safeScheme = scheme.replaceAll(/(\?|&)([^=]+)=([^&]+)/g, (_, prefix, key, value) =>
|
||||||
// Mask potential sensitive query parameters
|
// Mask potential sensitive query parameters
|
||||||
|
|
@ -443,7 +399,7 @@ ${content}
|
||||||
|
|
||||||
logger.info(`[URLScheme:${requestId}] Opening URL scheme`, {
|
logger.info(`[URLScheme:${requestId}] Opening URL scheme`, {
|
||||||
scheme: safeScheme,
|
scheme: safeScheme,
|
||||||
protocol,
|
protocol: scheme.split("://")[0],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use Electron's shell.openExternal to open URL scheme
|
// 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"> {
|
export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
|
||||||
/** Custom z-index for popover */
|
/** Custom z-index for popover */
|
||||||
zIndex?: number
|
zIndex?: number
|
||||||
/** Whether the popover should use modal focus and pointer behavior */
|
/** Whether the popover should close when clicked outside */
|
||||||
modal?: boolean
|
modal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,11 +33,6 @@ export const showPopover = (
|
||||||
element: ReactNode,
|
element: ReactNode,
|
||||||
props?: PopoverProps,
|
props?: PopoverProps,
|
||||||
) => {
|
) => {
|
||||||
const currentPopover = jotaiStore.get(popoverAtom)
|
|
||||||
if (currentPopover.open) {
|
|
||||||
currentPopover.abortController.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
jotaiStore.set(popoverAtom, {
|
jotaiStore.set(popoverAtom, {
|
||||||
open: true,
|
open: true,
|
||||||
position: mouseXY,
|
position: mouseXY,
|
||||||
|
|
@ -46,11 +41,3 @@ export const showPopover = (
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dismissPopover = () => {
|
|
||||||
const currentPopover = jotaiStore.get(popoverAtom)
|
|
||||||
if (!currentPopover.open) return
|
|
||||||
|
|
||||||
currentPopover.abortController.abort()
|
|
||||||
jotaiStore.set(popoverAtom, { open: false })
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
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,7 +5,6 @@ import { useCallback } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { dismissPopover } from "~/atoms/popover"
|
|
||||||
import { ipcServices } from "~/lib/client"
|
import { ipcServices } from "~/lib/client"
|
||||||
import { copyToClipboard } from "~/lib/clipboard"
|
import { copyToClipboard } from "~/lib/clipboard"
|
||||||
|
|
||||||
|
|
@ -141,13 +140,11 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
||||||
await copyToClipboard(shareContent.url)
|
await copyToClipboard(shareContent.url)
|
||||||
toast.success(t("share.link_copied"))
|
toast.success(t("share.link_copied"))
|
||||||
}
|
}
|
||||||
dismissPopover()
|
|
||||||
} catch {
|
} catch {
|
||||||
// If sharing fails, copy link as fallback
|
// If sharing fails, copy link as fallback
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(shareContent.url)
|
await copyToClipboard(shareContent.url)
|
||||||
toast.success(t("share.link_copied"))
|
toast.success(t("share.link_copied"))
|
||||||
dismissPopover()
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("share.copy_failed"))
|
toast.error(t("share.copy_failed"))
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +156,6 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(shareUrl)
|
await copyToClipboard(shareUrl)
|
||||||
toast.success(t("share.link_copied"))
|
toast.success(t("share.link_copied"))
|
||||||
dismissPopover()
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("share.copy_failed"))
|
toast.error(t("share.copy_failed"))
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +178,6 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
||||||
.replace("{text}", shareText)
|
.replace("{text}", shareText)
|
||||||
|
|
||||||
window.open(finalUrl, "_blank", "width=600,height=400")
|
window.open(finalUrl, "_blank", "width=600,height=400")
|
||||||
dismissPopover()
|
|
||||||
},
|
},
|
||||||
[entryId, generateShareContent],
|
[entryId, generateShareContent],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"
|
||||||
import type { TFunction } from "i18next"
|
import type { TFunction } from "i18next"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs"
|
import type { PaymentFeature, PaymentPlan } from "~/atoms/server-configs"
|
||||||
import { useIsPaymentEnabled, useServerConfigs } from "~/atoms/server-configs"
|
import { useIsPaymentEnabled, useServerConfigs } from "~/atoms/server-configs"
|
||||||
|
|
@ -18,15 +17,6 @@ import { followClient } from "~/lib/api-client"
|
||||||
import { subscription } from "~/lib/auth"
|
import { subscription } from "~/lib/auth"
|
||||||
|
|
||||||
const APPLE_SUBSCRIPTION_MANAGEMENT_URL = "https://apps.apple.com/account/subscriptions"
|
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 = {
|
type ActiveSubscription = {
|
||||||
source: "stripe" | "apple" | null
|
source: "stripe" | "apple" | null
|
||||||
|
|
@ -93,29 +83,6 @@ const formatFeatureValue = (
|
||||||
return value
|
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 }) => {
|
const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: boolean }) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
@ -130,20 +97,10 @@ const useUpgradePlan = ({ plan, annual }: { plan: string | undefined; annual: bo
|
||||||
cancelUrl: env.VITE_WEB_URL,
|
cancelUrl: env.VITE_WEB_URL,
|
||||||
disableRedirect: IN_ELECTRON,
|
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) {
|
if (IN_ELECTRON && res.data?.url) {
|
||||||
window.open(res.data.url, "_blank")
|
window.open(res.data.url, "_blank")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,9 +120,20 @@ const useActiveSubscription = () => {
|
||||||
|
|
||||||
const useBillingPortal = () => {
|
const useBillingPortal = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: openStripeBillingPortal,
|
mutationFn: async () => {
|
||||||
onError: (error) => {
|
const returnUrl = IN_ELECTRON ? env.VITE_WEB_URL : window.location.href
|
||||||
toast.error(error.message)
|
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")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,7 @@ function FeedCategoryImpl({
|
||||||
onClick={handleCollapseButtonClick}
|
onClick={handleCollapseButtonClick}
|
||||||
data-state={open ? "open" : "close"}
|
data-state={open ? "open" : "close"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 items-center data-[state=open]:[&_.i-mgc-right-cute-fi]:rotate-90",
|
"flex h-8 items-center [&_.i-mgc-right-cute-fi]:data-[state=open]:rotate-90",
|
||||||
)}
|
)}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
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,
|
PopoverTrigger,
|
||||||
} from "@follow/components/ui/popover/index.jsx"
|
} from "@follow/components/ui/popover/index.jsx"
|
||||||
import { AnimatePresence, m } from "motion/react"
|
import { AnimatePresence, m } from "motion/react"
|
||||||
import { memo, useEffect } from "react"
|
import { memo, useEffect, useRef } from "react"
|
||||||
|
|
||||||
import { dismissPopover, usePopoverValue } from "~/atoms/popover"
|
import { usePopoverState } from "~/atoms/popover"
|
||||||
import { HotkeyScope } from "~/constants"
|
import { HotkeyScope } from "~/constants"
|
||||||
|
|
||||||
export const PopoverProvider: Component = ({ children }) => (
|
export const PopoverProvider: Component = ({ children }) => (
|
||||||
|
|
@ -21,31 +21,36 @@ export const PopoverProvider: Component = ({ children }) => (
|
||||||
)
|
)
|
||||||
|
|
||||||
const Handler = memo(() => {
|
const Handler = memo(() => {
|
||||||
const popoverState = usePopoverValue()
|
const ref = useRef<HTMLButtonElement>(null)
|
||||||
|
const [popoverState, setPopoverState] = usePopoverState()
|
||||||
const setGlobalFocusableScope = useSetGlobalFocusableScope()
|
const setGlobalFocusableScope = useSetGlobalFocusableScope()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!popoverState.open) return
|
if (!popoverState.open) return
|
||||||
|
const triggerElement = ref.current
|
||||||
|
if (!triggerElement) return
|
||||||
|
|
||||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
|
triggerElement.dispatchEvent(
|
||||||
return () => {
|
new MouseEvent("click", {
|
||||||
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
|
bubbles: true,
|
||||||
}
|
cancelable: true,
|
||||||
}, [popoverState.open, setGlobalFocusableScope])
|
}),
|
||||||
|
)
|
||||||
const { modal, zIndex, ...contentProps } = popoverState.open ? (popoverState.props ?? {}) : {}
|
}, [popoverState])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
open={popoverState.open}
|
|
||||||
modal={modal}
|
|
||||||
onOpenChange={(state) => {
|
onOpenChange={(state) => {
|
||||||
if (!state) {
|
if (state) {
|
||||||
dismissPopover()
|
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
|
||||||
|
} else {
|
||||||
|
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
|
||||||
|
setPopoverState({ open: false })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
|
ref={ref}
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
style={
|
style={
|
||||||
popoverState.open
|
popoverState.open
|
||||||
|
|
@ -53,14 +58,9 @@ const Handler = memo(() => {
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AnimatePresence>
|
<PopoverContent asChild forceMount>
|
||||||
{popoverState.open && (
|
<AnimatePresence>
|
||||||
<PopoverContent
|
{popoverState.open && (
|
||||||
{...contentProps}
|
|
||||||
asChild
|
|
||||||
forceMount
|
|
||||||
style={{ ...contentProps.style, zIndex }}
|
|
||||||
>
|
|
||||||
<m.div
|
<m.div
|
||||||
className="mr-2 rounded-xl border bg-material-ultra-thick p-2 shadow-2xl backdrop-blur-background"
|
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 }}
|
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
|
@ -71,9 +71,9 @@ const Handler = memo(() => {
|
||||||
<PopoverArrow className="fill-border" />
|
<PopoverArrow className="fill-border" />
|
||||||
{popoverState.content}
|
{popoverState.content}
|
||||||
</m.div>
|
</m.div>
|
||||||
</PopoverContent>
|
)}
|
||||||
)}
|
</AnimatePresence>
|
||||||
</AnimatePresence>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "Folo",
|
"name": "Folo",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.12.0",
|
"version": "1.11.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Follow everything in one place",
|
"description": "Follow everything in one place",
|
||||||
"author": "Folo Team",
|
"author": "Folo Team",
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
"workbox-build": "7.4.1",
|
"workbox-build": "7.4.1",
|
||||||
"workbox-window": "7.4.1"
|
"workbox-window": "7.4.1"
|
||||||
},
|
},
|
||||||
"runtimeVersion": "1.12.0",
|
"runtimeVersion": "1.11.0",
|
||||||
"productName": "Folo",
|
"productName": "Folo",
|
||||||
"mainHash": "0fdc4db0cd19e2c2d6cf6c596aa1a62cc253b4c9612633470cb6b8d64fba403a"
|
"mainHash": "b672f321b6478cbaad0ad6e354d73ca3c8a79f90aaadb41c119499639ec66e87"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "1.12.0",
|
"version": "1.11.0",
|
||||||
"mode": "build",
|
"mode": "build",
|
||||||
"runtimeVersion": null,
|
"runtimeVersion": null,
|
||||||
"channel": null
|
"channel": null
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
|
|
||||||
import * as yaml from "js-yaml"
|
import yaml from "js-yaml"
|
||||||
import path from "pathe"
|
import path from "pathe"
|
||||||
|
|
||||||
const outDir = "./out/make"
|
const outDir = "./out/make"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import crypto from "node:crypto"
|
||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import { fileURLToPath, resolve } from "node:url"
|
import { fileURLToPath, resolve } from "node:url"
|
||||||
|
|
||||||
import * as yaml from "js-yaml"
|
import yaml from "js-yaml"
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||||
const basePath = resolve(__dirname, "../out/make/squirrel.windows/x64/")
|
const basePath = resolve(__dirname, "../out/make/squirrel.windows/x64/")
|
||||||
|
|
|
||||||
|
|
@ -111,17 +111,6 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
package: "is.follow",
|
package: "is.follow",
|
||||||
// Media selection uses system pickers; saving only needs write access on older Android versions.
|
|
||||||
blockedPermissions: [
|
|
||||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
|
||||||
"android.permission.CAMERA",
|
|
||||||
"android.permission.READ_EXTERNAL_STORAGE",
|
|
||||||
"android.permission.READ_MEDIA_AUDIO",
|
|
||||||
"android.permission.READ_MEDIA_IMAGES",
|
|
||||||
"android.permission.READ_MEDIA_VIDEO",
|
|
||||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
|
||||||
"android.permission.RECORD_AUDIO",
|
|
||||||
],
|
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
foregroundImage: adaptiveIconPath,
|
foregroundImage: adaptiveIconPath,
|
||||||
monochromeImage: adaptiveIconPath,
|
monochromeImage: adaptiveIconPath,
|
||||||
|
|
@ -169,8 +158,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||||
{
|
{
|
||||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
||||||
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
|
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
|
||||||
isAccessMediaLocationEnabled: false,
|
isAccessMediaLocationEnabled: true,
|
||||||
granularPermissions: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"expo-apple-authentication",
|
"expo-apple-authentication",
|
||||||
|
|
@ -204,8 +192,6 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||||
"expo-image-picker",
|
"expo-image-picker",
|
||||||
{
|
{
|
||||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
||||||
cameraPermission: false,
|
|
||||||
microphonePermission: false,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# What's New in v0.5.6
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
- Upgraded the app to Expo SDK 57, React Native 0.86, and updated native integrations
|
|
||||||
|
|
||||||
## No longer broken
|
|
||||||
|
|
||||||
- Fixed social sign-in callbacks and legacy session migration using the Folo app scheme
|
|
||||||
- Fixed signed-out launches not opening the login screen
|
|
||||||
- Fixed two-factor authentication cookies being dropped during session updates
|
|
||||||
- Fixed push notification registration after sign-in, including retries and token refreshes
|
|
||||||
- Fixed the timeline view selector overflowing on narrow screens
|
|
||||||
- Restored the header background after scrolling
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# What's New in v0.5.8
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
- Updated Android photo selection and saving to use system pickers and request only the permissions required
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
# What's New in vNEXT_VERSION
|
# What's New in vNEXT_VERSION
|
||||||
|
|
||||||
## Shiny new things
|
|
||||||
|
|
||||||
## Improvements
|
## Improvements
|
||||||
|
|
||||||
|
- Upgraded the app to Expo SDK 57, React Native 0.86, and updated native integrations
|
||||||
|
|
||||||
## No longer broken
|
## No longer broken
|
||||||
|
|
||||||
## Thanks
|
- Fixed social sign-in callbacks and legacy session migration using the Folo app scheme
|
||||||
|
- Fixed signed-out launches not opening the login screen
|
||||||
Special thanks to volunteer contributors @ for their valuable contributions
|
- Fixed two-factor authentication cookies being dropped during session updates
|
||||||
|
- Fixed push notification registration after sign-in, including retries and token refreshes
|
||||||
|
- Fixed the timeline view selector overflowing on narrow screens
|
||||||
|
- Restored the header background after scrolling
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.5.8</string>
|
<string>0.5.5</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>11</string>
|
<string>8</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@follow/mobile",
|
"name": "@follow/mobile",
|
||||||
"version": "0.5.8",
|
"version": "0.5.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "src/main.tsx",
|
"main": "src/main.tsx",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -135,7 +135,7 @@
|
||||||
"react-native-sheet-transitions": "0.1.2",
|
"react-native-sheet-transitions": "0.1.2",
|
||||||
"react-native-svg": "15.15.5",
|
"react-native-svg": "15.15.5",
|
||||||
"react-native-track-player": "4.1.2",
|
"react-native-track-player": "4.1.2",
|
||||||
"react-native-uikit-colors": "0.6.2",
|
"react-native-uikit-colors": "1.0.0",
|
||||||
"react-native-volume-manager": "2.0.8",
|
"react-native-volume-manager": "2.0.8",
|
||||||
"react-native-web": "0.21.2",
|
"react-native-web": "0.21.2",
|
||||||
"react-native-webview": "14.0.1",
|
"react-native-webview": "14.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"version": "0.5.8",
|
"version": "0.5.5",
|
||||||
"mode": "store",
|
"mode": "ota",
|
||||||
"runtimeVersion": null,
|
"runtimeVersion": "0.5.0",
|
||||||
"channel": null
|
"channel": "production"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
import createExpoConfig, { resolveRuntimeVersion } from "../app.config.base"
|
import { resolveRuntimeVersion } from "../app.config.base"
|
||||||
|
|
||||||
describe("resolveRuntimeVersion", () => {
|
describe("resolveRuntimeVersion", () => {
|
||||||
it("keeps the development runtime version stable", () => {
|
it("keeps the development runtime version stable", () => {
|
||||||
|
|
@ -33,42 +33,3 @@ describe("resolveRuntimeVersion", () => {
|
||||||
).toThrow(/OTA_RUNTIME_VERSION/i)
|
).toThrow(/OTA_RUNTIME_VERSION/i)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Android media permissions", () => {
|
|
||||||
const config = createExpoConfig({
|
|
||||||
config: {},
|
|
||||||
} as Parameters<typeof createExpoConfig>[0])
|
|
||||||
|
|
||||||
it("blocks broad media access that is not required by the app", () => {
|
|
||||||
expect(config.android?.blockedPermissions).toEqual([
|
|
||||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
|
||||||
"android.permission.CAMERA",
|
|
||||||
"android.permission.READ_EXTERNAL_STORAGE",
|
|
||||||
"android.permission.READ_MEDIA_AUDIO",
|
|
||||||
"android.permission.READ_MEDIA_IMAGES",
|
|
||||||
"android.permission.READ_MEDIA_VIDEO",
|
|
||||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
|
||||||
"android.permission.RECORD_AUDIO",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("configures media APIs for picker and write-only access", () => {
|
|
||||||
expect(config.plugins).toContainEqual([
|
|
||||||
"expo-media-library",
|
|
||||||
{
|
|
||||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
|
||||||
savePhotosPermission: "Allow $(PRODUCT_NAME) to save photos.",
|
|
||||||
isAccessMediaLocationEnabled: false,
|
|
||||||
granularPermissions: [],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(config.plugins).toContainEqual([
|
|
||||||
"expo-image-picker",
|
|
||||||
{
|
|
||||||
photosPermission: "Allow $(PRODUCT_NAME) to access your photos.",
|
|
||||||
cameraPermission: false,
|
|
||||||
microphonePermission: false,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { IMAGE_PROXY_URL } from "@follow/utils/img-proxy"
|
||||||
import ImageEditor from "@react-native-community/image-editor"
|
import ImageEditor from "@react-native-community/image-editor"
|
||||||
import * as FileSystem from "expo-file-system/legacy"
|
import * as FileSystem from "expo-file-system/legacy"
|
||||||
import type { ImageProps, ImageSource } from "expo-image"
|
import type { ImageProps, ImageSource } from "expo-image"
|
||||||
import { Asset, usePermissions } from "expo-media-library"
|
import { saveToLibraryAsync, usePermissions } from "expo-media-library"
|
||||||
import * as Sharing from "expo-sharing"
|
import * as Sharing from "expo-sharing"
|
||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
import { Image } from "react-native"
|
import { Image } from "react-native"
|
||||||
|
|
@ -145,7 +145,7 @@ export const saveImageToMediaLibrary = async ({ uri }: { uri: string }) => {
|
||||||
const croppedImage = await getImageData(uri)
|
const croppedImage = await getImageData(uri)
|
||||||
const filename = `${extractFilenameFromUrl(uri)}.png`
|
const filename = `${extractFilenameFromUrl(uri)}.png`
|
||||||
const { filePath, cleanup } = await createTempFile(croppedImage.base64, filename)
|
const { filePath, cleanup } = await createTempFile(croppedImage.base64, filename)
|
||||||
await Asset.create(filePath)
|
await saveToLibraryAsync(filePath)
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,7 +161,7 @@ export const saveImageToMediaLibrary = async ({ uri }: { uri: string }) => {
|
||||||
*/
|
*/
|
||||||
export function useSaveImageToMediaLibrary() {
|
export function useSaveImageToMediaLibrary() {
|
||||||
const [permissionResponse, requestPermission, getPermission] = usePermissions({
|
const [permissionResponse, requestPermission, getPermission] = usePermissions({
|
||||||
writeOnly: true,
|
granularPermissions: ["photo"],
|
||||||
})
|
})
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (uri: string) => {
|
async (uri: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
const FOLLOW_API_REQUEST_CONTEXT_PATTERN = /\r?\nRequest:[\s\S]*$/u
|
|
||||||
|
|
||||||
export const sanitizeErrorMessage = (message: string) =>
|
|
||||||
message.replace(FOLLOW_API_REQUEST_CONTEXT_PATTERN, "").trim()
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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,7 +5,6 @@ import { FetchError } from "ofetch"
|
||||||
import { getIsPaymentEnabled } from "@/src/atoms/server-configs"
|
import { getIsPaymentEnabled } from "@/src/atoms/server-configs"
|
||||||
import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog"
|
import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog"
|
||||||
|
|
||||||
import { sanitizeErrorMessage } from "./error-message"
|
|
||||||
import { toast } from "./toast"
|
import { toast } from "./toast"
|
||||||
|
|
||||||
export const getFetchErrorInfo = (
|
export const getFetchErrorInfo = (
|
||||||
|
|
@ -22,11 +21,11 @@ export const getFetchErrorInfo = (
|
||||||
const i18nKey = `errors:${code}` as any
|
const i18nKey = `errors:${code}` as any
|
||||||
const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)
|
const i18nMessage = t(i18nKey) === i18nKey ? message : t(i18nKey)
|
||||||
return {
|
return {
|
||||||
message: sanitizeErrorMessage(`${i18nMessage}${reason ? `: ${reason}` : ""}`),
|
message: `${i18nMessage}${reason ? `: ${reason}` : ""}`,
|
||||||
code,
|
code,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { message: sanitizeErrorMessage(error.message) }
|
return { message: error.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,15 +35,15 @@ export const getFetchErrorInfo = (
|
||||||
const i18nKey = `errors:${code}` as any
|
const i18nKey = `errors:${code}` as any
|
||||||
const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)
|
const i18nMessage = t(i18nKey) === i18nKey ? error.message : t(i18nKey)
|
||||||
return {
|
return {
|
||||||
message: sanitizeErrorMessage(i18nMessage),
|
message: i18nMessage,
|
||||||
code,
|
code,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { message: sanitizeErrorMessage(error.message) }
|
return { message: error.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { message: sanitizeErrorMessage(error.message) }
|
return { message: error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFetchErrorMessage = (error: Error) => {
|
export const getFetchErrorMessage = (error: Error) => {
|
||||||
|
|
@ -59,7 +58,7 @@ export const createErrorToaster = (title?: string) => (err: Error) =>
|
||||||
toastFetchError(err, { title })
|
toastFetchError(err, { title })
|
||||||
|
|
||||||
export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => {
|
export const toastFetchError = (error: Error, { title: _title }: { title?: string } = {}) => {
|
||||||
const fallbackMessage = sanitizeErrorMessage(error.message)
|
const { message: fallbackMessage } = error
|
||||||
let message = fallbackMessage
|
let message = fallbackMessage
|
||||||
let _reason = ""
|
let _reason = ""
|
||||||
let code: number | undefined
|
let code: number | undefined
|
||||||
|
|
@ -114,8 +113,6 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message = sanitizeErrorMessage(message)
|
|
||||||
|
|
||||||
// 2fa errors are handled by the form
|
// 2fa errors are handled by the form
|
||||||
if (code === 4007 || code === 4008) {
|
if (code === 4007 || code === 4008) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ const PLAN_FEATURE_ORDER: Array<keyof PaymentFeature> = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"]
|
const BILLING_SEGMENTS: BillingPeriod[] = ["monthly", "yearly"]
|
||||||
const ACTIVE_STRIPE_SUBSCRIPTION_EXISTS_ERROR_CODE = "ACTIVE_STRIPE_SUBSCRIPTION_EXISTS"
|
|
||||||
|
|
||||||
type SegmentLayout = {
|
type SegmentLayout = {
|
||||||
width: number
|
width: number
|
||||||
|
|
@ -111,14 +110,6 @@ type ActiveSubscription = {
|
||||||
canManage: boolean
|
canManage: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type BillingPortalResponse = {
|
|
||||||
code: number
|
|
||||||
data?: {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const currencyFormatter = (() => {
|
const currencyFormatter = (() => {
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
|
@ -434,25 +425,6 @@ export const PlanScreen: NavigationControllerView = () => {
|
||||||
return Math.round(total / paidPlans.length)
|
return Math.round(total / paidPlans.length)
|
||||||
}, [sortedPlans])
|
}, [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>({
|
const upgradeMutation = useMutation<void, Error, UpgradeVariables>({
|
||||||
mutationFn: async ({ planId, annual }) => {
|
mutationFn: async ({ planId, annual }) => {
|
||||||
const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId)
|
const selectedPlan = plans.find((plan: PaymentPlan) => plan.planID === planId)
|
||||||
|
|
@ -480,13 +452,6 @@ export const PlanScreen: NavigationControllerView = () => {
|
||||||
cancelUrl: proxyEnv.WEB_URL,
|
cancelUrl: proxyEnv.WEB_URL,
|
||||||
disableRedirect: true,
|
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 =
|
const redirectUrl =
|
||||||
typeof response === "object" && response && "data" in response && response.data
|
typeof response === "object" && response && "data" in response && response.data
|
||||||
|
|
@ -503,6 +468,24 @@ 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(() => {
|
const handleManageSubscription = useCallback(() => {
|
||||||
billingPortalMutation.mutate()
|
billingPortalMutation.mutate()
|
||||||
}, [billingPortalMutation])
|
}, [billingPortalMutation])
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,6 @@ import { proxyEnv } from "@/src/lib/proxy-env"
|
||||||
import { queryClient } from "@/src/lib/query-client"
|
import { queryClient } from "@/src/lib/query-client"
|
||||||
import { toast } from "@/src/lib/toast"
|
import { toast } from "@/src/lib/toast"
|
||||||
|
|
||||||
import {
|
|
||||||
buildAppleVerificationRequest,
|
|
||||||
isKnownAppleSubscriptionPurchase,
|
|
||||||
selectSignedTransactionInfo,
|
|
||||||
} from "./apple-iap-purchase"
|
|
||||||
|
|
||||||
const billingSubscriptionQueryKey = ["billingSubscription"]
|
const billingSubscriptionQueryKey = ["billingSubscription"]
|
||||||
|
|
||||||
type BillingSubscriptionResponse = {
|
type BillingSubscriptionResponse = {
|
||||||
|
|
@ -129,23 +123,16 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
|
||||||
const verifyPurchase = useCallback(
|
const verifyPurchase = useCallback(
|
||||||
async (purchase: Purchase) => {
|
async (purchase: Purchase) => {
|
||||||
const productId = purchase.productId
|
const productId = purchase.id
|
||||||
let signedTransactionInfo = selectSignedTransactionInfo(purchase.purchaseToken)
|
const jwsRepresentation =
|
||||||
|
purchase.purchaseToken ||
|
||||||
|
(await getTransactionJwsIOS(productId).catch(() => null)) ||
|
||||||
|
(await validateReceipt({ apple: { sku: productId } })
|
||||||
|
.then((result) => ("jwsRepresentation" in result ? result.jwsRepresentation : undefined))
|
||||||
|
.catch(() => {}))
|
||||||
|
|
||||||
if (!signedTransactionInfo) {
|
if (!jwsRepresentation) {
|
||||||
signedTransactionInfo = selectSignedTransactionInfo(
|
throw new Error(t("subscription.actions.upgrade_error"))
|
||||||
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<{
|
const response = await followClient.request<{
|
||||||
|
|
@ -153,21 +140,23 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
||||||
data: BillingSubscriptionResponse
|
data: BillingSubscriptionResponse
|
||||||
}>("/billing/apple/verify", {
|
}>("/billing/apple/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: buildAppleVerificationRequest(purchase, signedTransactionInfo),
|
body: {
|
||||||
|
signedTransactionInfo: jwsRepresentation,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.code !== 0) {
|
if (response.code !== 0) {
|
||||||
throw new Error("Failed to verify Apple subscription")
|
throw new Error("Failed to verify Apple subscription")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[validateReceipt],
|
[t, validateReceipt],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
Platform.OS !== "ios" ||
|
Platform.OS !== "ios" ||
|
||||||
!currentPurchase ||
|
!currentPurchase ||
|
||||||
!isKnownAppleSubscriptionPurchase(currentPurchase, knownSubscriptionIds)
|
!knownSubscriptionIds.has(currentPurchase.id)
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -291,7 +280,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
|
||||||
const restoredPurchases = availablePurchasesRef.current.filter((purchase) =>
|
const restoredPurchases = availablePurchasesRef.current.filter((purchase) =>
|
||||||
isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds),
|
knownSubscriptionIds.has(purchase.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (restoredPurchases.length === 0) {
|
if (restoredPurchases.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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 { Fragment, useEffect, useState } from "react"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
export const NotFoundContent = () => {
|
const NotFoundContent = () => {
|
||||||
const [glitchText, setGlitchText] = useState("404")
|
const [glitchText, setGlitchText] = useState("404")
|
||||||
const [isGlitching, setIsGlitching] = useState(false)
|
const [isGlitching, setIsGlitching] = useState(false)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { NotFoundContent } from "@client/components/common/404"
|
|
||||||
import { FeedIcon } from "@client/components/ui/feed-icon"
|
import { FeedIcon } from "@client/components/ui/feed-icon"
|
||||||
import { openInFollowApp } from "@client/lib/helper"
|
import { openInFollowApp } from "@client/lib/helper"
|
||||||
import { UrlBuilder } from "@client/lib/url-builder"
|
import { UrlBuilder } from "@client/lib/url-builder"
|
||||||
|
|
@ -11,7 +10,6 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
|
||||||
import { useTitle } from "@follow/hooks"
|
import { useTitle } from "@follow/hooks"
|
||||||
import { cn } from "@follow/utils/utils"
|
import { cn } from "@follow/utils/utils"
|
||||||
import type { SubscriptionWithFeed, UserProfile } from "@follow-app/client-sdk"
|
import type { SubscriptionWithFeed, UserProfile } from "@follow-app/client-sdk"
|
||||||
import { FollowAPIError } from "@follow-app/client-sdk"
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Fragment, memo, useState } from "react"
|
import { Fragment, memo, useState } from "react"
|
||||||
import { useParams } from "react-router"
|
import { useParams } from "react-router"
|
||||||
|
|
@ -91,43 +89,22 @@ export const Component = () => {
|
||||||
|
|
||||||
useTitle(user.data?.name)
|
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 (
|
return (
|
||||||
<Fragment>
|
<>
|
||||||
<UserHero user={user.data} />
|
{user.isLoading ? (
|
||||||
<Lists userId={user.data.id} />
|
<LoadingCircle size="large" className="center fixed inset-0" />
|
||||||
{/* 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 UserHero = ({ user }: { user: UserProfile }) => {
|
||||||
const subscriptions = useUserSubscriptionsQuery(user.id)
|
const subscriptions = useUserSubscriptionsQuery(user.id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { followClient } from "@client/lib/api-fetch"
|
||||||
import { getProviders } from "@client/lib/auth"
|
import { getProviders } from "@client/lib/auth"
|
||||||
import { getHydrateData } from "@client/lib/helper"
|
import { getHydrateData } from "@client/lib/helper"
|
||||||
import type { LoginHydrateData } from "@client/pages/(login)/login/metadata"
|
import type { LoginHydrateData } from "@client/pages/(login)/login/metadata"
|
||||||
import { sortByAlphabet } from "@follow/utils/utils"
|
import { isBizId, sortByAlphabet } from "@follow/utils/utils"
|
||||||
import type {
|
import type {
|
||||||
InboxSubscriptionResponse,
|
InboxSubscriptionResponse,
|
||||||
ListSubscriptionResponse,
|
ListSubscriptionResponse,
|
||||||
|
|
@ -10,8 +10,6 @@ import type {
|
||||||
} from "@follow-app/client-sdk"
|
} from "@follow-app/client-sdk"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
|
||||||
import { getUserProfile } from "../../src/lib/user-profile-params"
|
|
||||||
|
|
||||||
type GetUserSubscriptionsResponse = (
|
type GetUserSubscriptionsResponse = (
|
||||||
SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse
|
SubscriptionWithFeed | ListSubscriptionResponse | InboxSubscriptionResponse
|
||||||
)[]
|
)[]
|
||||||
|
|
@ -71,7 +69,13 @@ export const useUserSubscriptionsQuery = (userId: string | undefined) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchUser = async (handleOrId: string | undefined) => {
|
export const fetchUser = async (handleOrId: string | undefined) => {
|
||||||
const res = await getUserProfile(followClient, handleOrId)
|
const handle = isBizId(handleOrId || "")
|
||||||
|
? handleOrId
|
||||||
|
: `${handleOrId}`.startsWith("@")
|
||||||
|
? `${handleOrId}`.slice(1)
|
||||||
|
: handleOrId
|
||||||
|
|
||||||
|
const res = await followClient.api.profiles.getProfile({ id: handleOrId, handle })
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
"dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
|
"dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
|
||||||
"meta": "tsx helper/meta-map.ts --watch",
|
"meta": "tsx helper/meta-map.ts --watch",
|
||||||
"start": "tsx index.ts",
|
"start": "tsx index.ts",
|
||||||
"test": "vitest run --config vitest.config.ts",
|
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
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",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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,7 +9,6 @@ import { FetchError } from "ofetch"
|
||||||
import path, { dirname, resolve } from "pathe"
|
import path, { dirname, resolve } from "pathe"
|
||||||
import xss from "xss"
|
import xss from "xss"
|
||||||
|
|
||||||
import { injectHydrationScript } from "../lib/hydration-script"
|
|
||||||
import { NotFoundError } from "../lib/not-found"
|
import { NotFoundError } from "../lib/not-found"
|
||||||
import { buildSeoMetaTags } from "../lib/seo"
|
import { buildSeoMetaTags } from "../lib/seo"
|
||||||
import { injectMetaHandler, MetaError } from "../meta-handler"
|
import { injectMetaHandler, MetaError } from "../meta-handler"
|
||||||
|
|
@ -152,7 +151,13 @@ async function injectMetaToTemplate(document: Document, req: FastifyRequest, res
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "hydrate": {
|
case "hydrate": {
|
||||||
injectHydrationScript(document, meta.key, meta.data)
|
// 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)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,7 @@ export const ogRoute = (app: FastifyInstance) => {
|
||||||
|
|
||||||
const createErrorFallback = (reply: FastifyReply) => (code: number | Error) => {
|
const createErrorFallback = (reply: FastifyReply) => (code: number | Error) => {
|
||||||
if (typeof code !== "number" && code instanceof Error) {
|
if (typeof code !== "number" && code instanceof Error) {
|
||||||
console.error("OG render error:", code)
|
reply.code(500).send(code.message)
|
||||||
reply.code(500).send("Internal server error")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
let message = "Internal server error"
|
let message = "Internal server error"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
|
import { isBizId } from "@follow/utils/utils"
|
||||||
import type { FollowClient } from "@follow-app/client-sdk"
|
import type { FollowClient } from "@follow-app/client-sdk"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { renderToImage } from "../../lib/og/render-to-image"
|
import { renderToImage } from "../../lib/og/render-to-image"
|
||||||
import { getUserProfile } from "../../lib/user-profile-params"
|
|
||||||
import { getImageBase64, OGAvatar, OGCanvas } from "./__base"
|
import { getImageBase64, OGAvatar, OGCanvas } from "./__base"
|
||||||
|
|
||||||
export const renderUserOG = async (apiClient: FollowClient, handleOrId: string) => {
|
export const renderUserOG = async (apiClient: FollowClient, id: string) => {
|
||||||
const user = await getUserProfile(apiClient, handleOrId)
|
const handle = isBizId(id || "") ? id : `${id}`.startsWith("@") ? `${id}`.slice(1) : id
|
||||||
|
|
||||||
|
const user = await apiClient.api.profiles.getProfile({
|
||||||
|
id,
|
||||||
|
handle,
|
||||||
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw 404
|
throw 404
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@
|
||||||
"./client/**/*.tsx",
|
"./client/**/*.tsx",
|
||||||
"./types/**/*.d.ts",
|
"./types/**/*.d.ts",
|
||||||
"vite.config.mts",
|
"vite.config.mts",
|
||||||
"vitest.config.ts",
|
|
||||||
"./tailwind.config.ts",
|
"./tailwind.config.ts",
|
||||||
"./helper/**/*.ts"
|
"./helper/**/*.ts"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { defineProject } from "vitest/config"
|
|
||||||
|
|
||||||
export default defineProject({
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -12,7 +12,6 @@ import xss from "xss"
|
||||||
import resvgWasm from "./resvg.wasm"
|
import resvgWasm from "./resvg.wasm"
|
||||||
// OG image rendering
|
// OG image rendering
|
||||||
import { createFollowClient } from "./src/lib/api-client"
|
import { createFollowClient } from "./src/lib/api-client"
|
||||||
import { injectHydrationScript } from "./src/lib/hydration-script"
|
|
||||||
import { NotFoundError } from "./src/lib/not-found"
|
import { NotFoundError } from "./src/lib/not-found"
|
||||||
import { setFontsBucket } from "./src/lib/og/fonts.worker"
|
import { setFontsBucket } from "./src/lib/og/fonts.worker"
|
||||||
import { setWasmModule } from "./src/lib/og/resvg-wasm-shim"
|
import { setWasmModule } from "./src/lib/og/resvg-wasm-shim"
|
||||||
|
|
@ -128,7 +127,7 @@ app.get("/og/:type/:id", async (c) => {
|
||||||
return c.text(e === 404 ? "Not found" : "Internal server error", e)
|
return c.text(e === 404 ? "Not found" : "Internal server error", e)
|
||||||
}
|
}
|
||||||
console.error("OG render error:", e)
|
console.error("OG render error:", e)
|
||||||
return c.text("Internal server error", 500)
|
return c.text(e?.message || "Internal server error", 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!imageRes) {
|
if (!imageRes) {
|
||||||
|
|
@ -299,7 +298,12 @@ async function injectMetaToTemplate(document: Document, c: any) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "hydrate": {
|
case "hydrate": {
|
||||||
injectHydrationScript(document, meta.key, meta.data)
|
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)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,7 @@
|
||||||
"name": "folo-ssr",
|
"name": "folo-ssr",
|
||||||
"main": "dist/worker/worker-entry.mjs",
|
"main": "dist/worker/worker-entry.mjs",
|
||||||
"compatibility_date": "2026-02-01",
|
"compatibility_date": "2026-02-01",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
"nodejs_compat",
|
|
||||||
// The SSR Worker fetches api.folo.is, another Worker Route in the same zone.
|
|
||||||
"global_fetch_strictly_public",
|
|
||||||
],
|
|
||||||
"observability": {
|
"observability": {
|
||||||
"logs": {
|
"logs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"2004": "Feed failed to parse",
|
"2004": "Feed failed to parse",
|
||||||
"2010": "Ownership challenge failed",
|
"2010": "Ownership challenge failed",
|
||||||
"2011": "Subscription limit exceeded",
|
"2011": "Subscription limit exceeded",
|
||||||
"2012": "RSSHub feed subscription limit exceeded",
|
|
||||||
"3000": "Entry not found",
|
"3000": "Entry not found",
|
||||||
"4000": "Already claimed",
|
"4000": "Already claimed",
|
||||||
"4001": "User wallet error",
|
"4001": "User wallet error",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"2004": "Échec de l'analyse du flux",
|
"2004": "Échec de l'analyse du flux",
|
||||||
"2010": "Échec de la vérification de propriété",
|
"2010": "Échec de la vérification de propriété",
|
||||||
"2011": "Limite d'abonnement dépassée",
|
"2011": "Limite d'abonnement dépassée",
|
||||||
"2012": "Limite d'abonnement aux flux RSSHub dépassée",
|
|
||||||
"3000": "Entrée introuvable",
|
"3000": "Entrée introuvable",
|
||||||
"4000": "Déjà réclamé",
|
"4000": "Déjà réclamé",
|
||||||
"4001": "Erreur de portefeuille utilisateur",
|
"4001": "Erreur de portefeuille utilisateur",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"2004": "フィードの解析に失敗しました",
|
"2004": "フィードの解析に失敗しました",
|
||||||
"2010": "所有権の確認に失敗しました",
|
"2010": "所有権の確認に失敗しました",
|
||||||
"2011": "購読制限を超えました",
|
"2011": "購読制限を超えました",
|
||||||
"2012": "RSSHubフィードの購読数制限を超えました",
|
|
||||||
"3000": "エントリが見つかりません",
|
"3000": "エントリが見つかりません",
|
||||||
"4000": "すでに請求されています",
|
"4000": "すでに請求されています",
|
||||||
"4001": "ユーザーウォレットエラー",
|
"4001": "ユーザーウォレットエラー",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"2004": "订阅源解析失败",
|
"2004": "订阅源解析失败",
|
||||||
"2010": "所有权挑战失败",
|
"2010": "所有权挑战失败",
|
||||||
"2011": "超出订阅限制",
|
"2011": "超出订阅限制",
|
||||||
"2012": "RSSHub 订阅源数量限制已超出",
|
|
||||||
"3000": "未找到条目",
|
"3000": "未找到条目",
|
||||||
"4000": "已被认领",
|
"4000": "已被认领",
|
||||||
"4001": "用户钱包错误",
|
"4001": "用户钱包错误",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"2004": "訂閱源解析失敗",
|
"2004": "訂閱源解析失敗",
|
||||||
"2010": "所有權挑戰失敗",
|
"2010": "所有權挑戰失敗",
|
||||||
"2011": "超過訂閱限制",
|
"2011": "超過訂閱限制",
|
||||||
"2012": "RSSHub 訂閱源數量限制已超過",
|
|
||||||
"3000": "條目未找到",
|
"3000": "條目未找到",
|
||||||
"4000": "已被認領",
|
"4000": "已被認領",
|
||||||
"4001": "用戶錢包錯誤",
|
"4001": "用戶錢包錯誤",
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .kbd {
|
[data-theme="dark"] .kbd {
|
||||||
border: 1px solid hsl(var(--fo-border) / 0.5);
|
border: 1px solid hsl(var(--border) / 0.5);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 2px 8px rgba(0, 0, 0, 0.12),
|
0 2px 8px rgba(0, 0, 0, 0.12),
|
||||||
0 1px 4px rgba(0, 0, 0, 0.08),
|
0 1px 4px rgba(0, 0, 0, 0.08),
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,7 @@ const twConfig = {
|
||||||
},
|
},
|
||||||
|
|
||||||
colors: {
|
colors: {
|
||||||
// DaisyUI 5 reserves --border for border width, so keep the color token namespaced.
|
border: "hsl(var(--border) / <alpha-value>)",
|
||||||
border: "hsl(var(--fo-border) / <alpha-value>)",
|
|
||||||
background: "hsl(var(--background) / <alpha-value>)",
|
background: "hsl(var(--background) / <alpha-value>)",
|
||||||
|
|
||||||
accent: "hsl(var(--fo-a) / <alpha-value>)",
|
accent: "hsl(var(--fo-a) / <alpha-value>)",
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--color-background: 255 255 255;
|
--color-background: 255 255 255;
|
||||||
|
|
||||||
--fo-border: 20 5.9% 90%;
|
--border: 20 5.9% 90%;
|
||||||
--border: var(--fo-border);
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
--fo-selection-active: theme(colors.accent/90);
|
--fo-selection-active: theme(colors.accent/90);
|
||||||
|
|
@ -40,8 +39,7 @@
|
||||||
|
|
||||||
--background: 0 0% 7.1%;
|
--background: 0 0% 7.1%;
|
||||||
--color-background: 18 18 18;
|
--color-background: 18 18 18;
|
||||||
--fo-border: 0 0% 22.1%;
|
--border: 0 0% 22.1%;
|
||||||
--border: var(--fo-border);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--color-background: 255 255 255;
|
--color-background: 255 255 255;
|
||||||
|
|
||||||
--fo-border: 20 5.9% 90%;
|
--border: 20 5.9% 90%;
|
||||||
--border: var(--fo-border);
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,8 +18,7 @@
|
||||||
--background: 0 0% 7.1%;
|
--background: 0 0% 7.1%;
|
||||||
--color-background: 18 18 18;
|
--color-background: 18 18 18;
|
||||||
|
|
||||||
--fo-border: 0 0% 22.1%;
|
--border: 0 0% 22.1%;
|
||||||
--border: var(--fo-border);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1398,8 +1398,8 @@ importers:
|
||||||
specifier: 4.1.2
|
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)
|
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:
|
react-native-uikit-colors:
|
||||||
specifier: 0.6.2
|
specifier: 1.0.0
|
||||||
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)))
|
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)))
|
||||||
react-native-volume-manager:
|
react-native-volume-manager:
|
||||||
specifier: 2.0.8
|
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)
|
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,9 +10314,6 @@ packages:
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
apple-uikit-colors@0.6.2:
|
|
||||||
resolution: {integrity: sha512-sv0b92krbTZGNt4AJQLAPioUvuEI/wW2cLqGuEBWl3KpU2JK/odOftx8+dGSC5jGWhdB5qnwBqF96X2FA8FUQA==}
|
|
||||||
|
|
||||||
apple-uikit-colors@1.0.0:
|
apple-uikit-colors@1.0.0:
|
||||||
resolution: {integrity: sha512-G2Ti2ogMOOC1phfHacSrLacDiE0RcLI7IG3aNoaw7Ack23WI8/9QczmUuhxxkU9BoIR2euCZ2G4lD5qJ+OknwA==}
|
resolution: {integrity: sha512-G2Ti2ogMOOC1phfHacSrLacDiE0RcLI7IG3aNoaw7Ack23WI8/9QczmUuhxxkU9BoIR2euCZ2G4lD5qJ+OknwA==}
|
||||||
|
|
||||||
|
|
@ -16814,14 +16811,6 @@ packages:
|
||||||
shaka-player:
|
shaka-player:
|
||||||
optional: true
|
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:
|
react-native-uikit-colors@1.0.0:
|
||||||
resolution: {integrity: sha512-DEEc/OTrNNkIgfgbjSsG4pL0pvBaqtwmjN4FCITHQYeJFupxPOcGJehatMfPE6x3QItyIe45C0sYZz/ish+x6w==}
|
resolution: {integrity: sha512-DEEc/OTrNNkIgfgbjSsG4pL0pvBaqtwmjN4FCITHQYeJFupxPOcGJehatMfPE6x3QItyIe45C0sYZz/ish+x6w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -17913,9 +17902,6 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: ^4.0.0
|
tailwindcss: ^4.0.0
|
||||||
|
|
||||||
tailwindcss-uikit-colors@0.6.2:
|
|
||||||
resolution: {integrity: sha512-RfF3VVB2nHvJwPZ2ZSTMgKYbZjRny/KW3rP6ufg6/bopxINDQ9TpN/fJXxvKnD86hIU0F2oLIfAv2r6o/ZWcZw==}
|
|
||||||
|
|
||||||
tailwindcss-uikit-colors@1.0.0:
|
tailwindcss-uikit-colors@1.0.0:
|
||||||
resolution: {integrity: sha512-18MGdMVSoXFKjcVkUIu7Q5USqdenAvgFJT9DEocPmdS94b8d/z3s0Uw8LvaNDINfDN9ls24hfg6/hyFqpcZpxw==}
|
resolution: {integrity: sha512-18MGdMVSoXFKjcVkUIu7Q5USqdenAvgFJT9DEocPmdS94b8d/z3s0Uw8LvaNDINfDN9ls24hfg6/hyFqpcZpxw==}
|
||||||
|
|
||||||
|
|
@ -29548,8 +29534,6 @@ snapshots:
|
||||||
repeat-string: 1.6.1
|
repeat-string: 1.6.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
apple-uikit-colors@0.6.2: {}
|
|
||||||
|
|
||||||
apple-uikit-colors@1.0.0: {}
|
apple-uikit-colors@1.0.0: {}
|
||||||
|
|
||||||
archiver-utils@2.1.0:
|
archiver-utils@2.1.0:
|
||||||
|
|
@ -37515,15 +37499,6 @@ snapshots:
|
||||||
react: 19.2.7
|
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: 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))):
|
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:
|
dependencies:
|
||||||
apple-uikit-colors: 1.0.0
|
apple-uikit-colors: 1.0.0
|
||||||
|
|
@ -38911,10 +38886,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
tailwindcss: 4.3.2
|
tailwindcss: 4.3.2
|
||||||
|
|
||||||
tailwindcss-uikit-colors@0.6.2:
|
|
||||||
dependencies:
|
|
||||||
apple-uikit-colors: 0.6.2
|
|
||||||
|
|
||||||
tailwindcss-uikit-colors@1.0.0:
|
tailwindcss-uikit-colors@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
apple-uikit-colors: 1.0.0
|
apple-uikit-colors: 1.0.0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue