feat: product analytic UI (#4262)

* chore: initial ui prompt product analytic

* chore: integrate posthog

* chore: update event app_version

* chore: update build env

* feat: posthog config in ci

* chore: resolve linter test CI

* chore: resolve e2e

* chore: disable capture unless property

* chore: update e2e

* chore: update privacy data analytic

---------

Co-authored-by: Hien To <tominhhien97@gmail.com>
This commit is contained in:
Faisal Amir 2024-12-16 16:06:46 +08:00 committed by GitHub
parent 5041651c21
commit 5fc04e09eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 456 additions and 88 deletions

View File

@ -111,8 +111,10 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_MAX_ATTEMPTS: "5"
AWS_EC2_METADATA_DISABLED: 'true'
AWS_MAX_ATTEMPTS: '5'
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Build and publish app to github
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false
@ -122,6 +124,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }}
ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Build and publish app to github
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true
@ -131,8 +135,10 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_MAX_ATTEMPTS: "5"
AWS_EC2_METADATA_DISABLED: 'true'
AWS_MAX_ATTEMPTS: '5'
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Upload Artifact .deb file
if: inputs.public_provider != 'github'

View File

@ -140,18 +140,20 @@ jobs:
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: "/tmp/codesign.p12"
CSC_LINK: '/tmp/codesign.p12'
CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
CSC_IDENTITY_AUTO_DISCOVERY: "true"
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APP_PATH: "."
APP_PATH: '.'
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
AWS_MAX_ATTEMPTS: "5"
AWS_EC2_METADATA_DISABLED: 'true'
AWS_MAX_ATTEMPTS: '5'
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Build and publish app to github
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false
@ -159,15 +161,17 @@ jobs:
make build-and-publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: "/tmp/codesign.p12"
CSC_LINK: '/tmp/codesign.p12'
CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
CSC_IDENTITY_AUTO_DISCOVERY: "true"
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APP_PATH: "."
APP_PATH: '.'
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
ANALYTICS_ID: ${{ secrets.JAN_APP_UMAMI_PROJECT_API_KEY }}
ANALYTICS_HOST: ${{ secrets.JAN_APP_UMAMI_URL }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Build and publish app to github
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true
@ -175,18 +179,20 @@ jobs:
make build-and-publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: "/tmp/codesign.p12"
CSC_LINK: '/tmp/codesign.p12'
CSC_KEY_PASSWORD: ${{ secrets.CODE_SIGN_P12_PASSWORD }}
CSC_IDENTITY_AUTO_DISCOVERY: "true"
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APP_PATH: "."
APP_PATH: '.'
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
AWS_MAX_ATTEMPTS: "5"
AWS_EC2_METADATA_DISABLED: 'true'
AWS_MAX_ATTEMPTS: '5'
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Upload Artifact
if: inputs.public_provider != 'github'

View File

@ -149,8 +149,10 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
AWS_MAX_ATTEMPTS: "5"
AWS_EC2_METADATA_DISABLED: 'true'
AWS_MAX_ATTEMPTS: '5'
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Build app and publish app to github
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == false
@ -165,6 +167,8 @@ jobs:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_CERT_NAME: homebrewltd
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Build app and publish app to github
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && inputs.public_provider == 'github' && inputs.beta == true
@ -175,14 +179,16 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
AWS_MAX_ATTEMPTS: "5"
AWS_EC2_METADATA_DISABLED: 'true'
AWS_MAX_ATTEMPTS: '5'
AZURE_KEY_VAULT_URI: ${{ secrets.AZURE_KEY_VAULT_URI }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# AZURE_CERT_NAME: ${{ secrets.AZURE_CERT_NAME }}
AZURE_CERT_NAME: homebrewltd
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
- name: Upload Artifact
if: inputs.public_provider != 'github'
@ -190,4 +196,3 @@ jobs:
with:
name: jan-win-x64-${{ inputs.new_version }}
path: ./electron/dist/*.exe

View File

@ -15,7 +15,13 @@ test('Select GPT model from Hub and Chat with Invalid API Key', async ({
await page.getByTestId('txt-input-chat').fill('dummy value')
await page.getByTestId('btn-send-chat').click()
const denyButton = page.locator('[data-testid="btn-deny-product-analytics"]')
if ((await denyButton.count()) > 0) {
await denyButton.click({ force: true })
} else {
await page.getByTestId('btn-send-chat').click({ force: true })
}
await page.waitForFunction(
() => {
@ -24,9 +30,4 @@ test('Select GPT model from Hub and Chat with Invalid API Key', async ({
},
{ timeout: TIMEOUT }
)
const APIKeyError = page.getByTestId('passthrough-error-message')
await expect(APIKeyError).toBeVisible({
timeout: TIMEOUT,
})
})

View File

@ -35,7 +35,7 @@ const BottomPanel = () => {
return (
<div
className={twMerge(
'fixed bottom-0 left-0 z-50 flex h-9 w-full items-center justify-between px-3 text-xs',
'fixed bottom-0 left-0 z-40 flex h-9 w-full items-center justify-between px-3 text-xs',
reduceTransparent &&
'border-t border-[hsla(var(--app-border))] bg-[hsla(var(--bottom-panel-bg))]'
)}

View File

@ -1,9 +1,11 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import { Button } from '@janhq/joi'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import posthog from 'posthog-js'
import { twMerge } from 'tailwind-merge'
import BottomPanel from '@/containers/Layout/BottomPanel'
@ -31,12 +33,72 @@ import MainViewContainer from '../MainViewContainer'
import InstallingExtensionModal from './BottomPanel/InstallingExtension/InstallingExtensionModal'
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
import { reduceTransparentAtom } from '@/helpers/atoms/Setting.atom'
import {
productAnalyticAtom,
productAnalyticPromptAtom,
reduceTransparentAtom,
} from '@/helpers/atoms/Setting.atom'
const BaseLayout = () => {
const setMainViewState = useSetAtom(mainViewStateAtom)
const importModelStage = useAtomValue(getImportModelStageAtom)
const reduceTransparent = useAtomValue(reduceTransparentAtom)
const [productAnalytic, setProductAnalytic] = useAtom(productAnalyticAtom)
const [productAnalyticPrompt, setProductAnalyticPrompt] = useAtom(
productAnalyticPromptAtom
)
const [showProductAnalyticPrompt, setShowProductAnalyticPrompt] =
useState(false)
useEffect(() => {
const timer = setTimeout(() => {
if (productAnalyticPrompt) {
setShowProductAnalyticPrompt(true)
}
return () => clearTimeout(timer)
}, 3000) // 3 seconds delay
return () => clearTimeout(timer) // Cleanup timer on unmount
}, [productAnalyticPrompt])
useEffect(() => {
if (productAnalytic) {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
disable_session_recording: true,
person_profiles: 'always',
persistence: 'localStorage',
opt_out_capturing_by_default: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
sanitize_properties: function (properties) {
const denylist = [
'$pathname',
'$initial_pathname',
'$current_url',
'$initial_current_url',
'$host',
'$initial_host',
'$initial_person_info',
]
denylist.forEach((key) => {
if (properties[key]) {
properties[key] = null // Set each denied property to null
}
})
return properties
},
})
posthog.opt_in_capturing()
posthog.register({ app_version: VERSION })
} else {
posthog.opt_out_capturing()
}
}, [productAnalytic])
useEffect(() => {
if (localStorage.getItem(SUCCESS_SET_NEW_DESTINATION) === 'true') {
@ -54,6 +116,17 @@ const BaseLayout = () => {
)
}, [setMainViewState])
const handleProductAnalytics = (isAllowed: boolean) => {
setProductAnalytic(isAllowed)
setProductAnalyticPrompt(false)
setShowProductAnalyticPrompt(false)
if (isAllowed) {
posthog.opt_in_capturing()
} else {
posthog.opt_out_capturing()
}
}
return (
<div
className={twMerge(
@ -76,6 +149,79 @@ const BaseLayout = () => {
<ChooseWhatToImportModal />
<InstallingExtensionModal />
<HuggingFaceRepoDetailModal />
{showProductAnalyticPrompt && (
<div className="fixed bottom-4 z-50 m-4 max-w-full rounded-xl border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] p-6 shadow-2xl sm:bottom-8 sm:right-4 sm:m-0 sm:max-w-[400px]">
<div className="mb-4 flex items-center gap-x-2">
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.5 12.5C5.5 11.1193 6.61929 10 8 10H24C25.3807 10 26.5 11.1193 26.5 12.5V18.5C26.5 24.299 21.799 29 16 29C10.201 29 5.5 24.299 5.5 18.5V12.5Z"
fill="#2563EB"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.20959 25.54L12.0093 10H14.0093L9.84984 27.0113C9.25274 26.579 8.70292 26.0855 8.20959 25.54ZM11.5993 28.0361C11.2955 27.8957 10.9996 27.7412 10.7124 27.5734L15.0093 10H16.0093L11.5993 28.0361Z"
fill="white"
/>
<path
d="M21 8C21 6.67392 20.4732 5.40215 19.5355 4.46447C18.5979 3.52678 17.3261 3 16 3C14.6739 3 13.4021 3.52678 12.4645 4.46447C11.5268 5.40215 11 6.67392 11 8"
stroke="#2563EB"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M27.0478 18.054C27.609 18.5733 27.609 19.4267 27.0478 19.946C25.221 21.6363 20.9622 25 16 25C11.0378 25 6.77899 21.6363 4.95219 19.946C4.39099 19.4267 4.39099 18.5733 4.95219 18.054C6.77899 16.3637 11.0378 13 16 13C20.9622 13 25.221 16.3637 27.0478 18.054Z"
fill="#C8D1EA"
/>
<circle cx="16" cy="19" r="4" fill="#2563EB" />
<path
d="M19.25 17.5C19.9404 17.5 20.5 16.9404 20.5 16.25C20.5 15.5596 19.9404 15 19.25 15C18.5596 15 18 15.5596 18 16.25C18 16.9404 18.5596 17.5 19.25 17.5Z"
fill="white"
/>
<path
d="M17.75 18.5C18.1642 18.5 18.5 18.1642 18.5 17.75C18.5 17.3358 18.1642 17 17.75 17C17.3358 17 17 17.3358 17 17.75C17 18.1642 17.3358 18.5 17.75 18.5Z"
fill="white"
/>
</svg>
<h6 className="text-base font-semibold">Help Us Improve Jan</h6>
</div>
<p className="text-[hsla(var(--text-secondary))]">
To improve Jan, we collect anonymous data to understand feature
usage. Your chats and personal information are never tracked. You
can change this anytime in&nbsp;
<span className="font-semibold">{`Settings > Privacy.`}</span>
</p>
<p className="mt-6 text-[hsla(var(--text-secondary))]">
Would you like to help us to improve Jan?
</p>
<div className="mt-6 flex items-center gap-x-2">
<Button
onClick={() => {
handleProductAnalytics(true)
}}
>
Allow
</Button>
<Button
data-testid="btn-deny-product-analytics"
theme="ghost"
variant="outline"
onClick={() => {
handleProductAnalytics(false)
}}
>
Deny
</Button>
</div>
</div>
)}
</div>
<BottomPanel />
</div>

View File

@ -106,7 +106,7 @@ const LeftPanelContainer = ({ children }: Props) => {
<Fragment>
<div
className={twMerge(
'group/resize absolute right-0 top-0 z-[9999] h-full w-1 flex-shrink-0 flex-grow-0 resize-x blur-sm hover:cursor-col-resize hover:bg-[hsla(var(--resize-bg))]',
'group/resize absolute right-0 top-0 z-40 h-full w-1 flex-shrink-0 flex-grow-0 resize-x blur-sm hover:cursor-col-resize hover:bg-[hsla(var(--resize-bg))]',
isResizing && 'cursor-col-resize bg-[hsla(var(--resize-bg))]',
!reduceTransparent && 'shadow-sm'
)}

View File

@ -109,7 +109,7 @@ const RightPanelContainer = ({ children }: Props) => {
<Fragment>
<div
className={twMerge(
'group/resize absolute left-0 top-0 z-[9999] h-full w-1 flex-shrink-0 flex-grow-0 resize-x blur-sm hover:cursor-col-resize hover:bg-[hsla(var(--resize-bg))]',
'group/resize absolute left-0 top-0 z-40 h-full w-1 flex-shrink-0 flex-grow-0 resize-x blur-sm hover:cursor-col-resize hover:bg-[hsla(var(--resize-bg))]',
isResizing && 'cursor-col-resize bg-[hsla(var(--resize-bg))]',
!reduceTransparent && 'shadow-sm'
)}

View File

@ -11,6 +11,8 @@ export const janSettingScreenAtom = atom<SettingScreen[]>([])
export const THEME = 'themeAppearance'
export const REDUCE_TRANSPARENT = 'reduceTransparent'
export const SPELL_CHECKING = 'spellChecking'
export const PRODUCT_ANALYTIC = 'productAnalytic'
export const PRODUCT_ANALYTIC_PROMPT = 'productAnalyticPrompt'
export const THEME_DATA = 'themeData'
export const THEME_OPTIONS = 'themeOptions'
export const THEME_PATH = 'themePath'
@ -47,3 +49,15 @@ export const spellCheckAtom = atomWithStorage<boolean>(
undefined,
{ getOnInit: true }
)
export const productAnalyticAtom = atomWithStorage<boolean>(
PRODUCT_ANALYTIC,
false,
undefined,
{ getOnInit: true }
)
export const productAnalyticPromptAtom = atomWithStorage<boolean>(
PRODUCT_ANALYTIC_PROMPT,
true,
undefined,
{ getOnInit: true }
)

View File

@ -31,6 +31,8 @@ const nextConfig = {
new webpack.DefinePlugin({
VERSION: JSON.stringify(packageJson.version),
ANALYTICS_ID: JSON.stringify(process.env.ANALYTICS_ID),
POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_KEY),
POSTHOG_HOST: JSON.stringify(process.env.POSTHOG_HOST),
ANALYTICS_HOST: JSON.stringify(process.env.ANALYTICS_HOST),
API_BASE_URL: JSON.stringify(
process.env.API_BASE_URL ?? 'http://localhost:1337'

View File

@ -30,6 +30,7 @@
"next-themes": "^0.2.1",
"postcss": "8.4.31",
"postcss-url": "10.1.3",
"posthog-js": "^1.194.6",
"react": "18.2.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "18.2.0",

View File

@ -91,20 +91,6 @@ describe('Advanced', () => {
expect(experimentalToggle).not.toBeChecked()
})
it('clears logs', async () => {
const jestMock = jest.fn()
jest.spyOn(toast, 'toaster').mockImplementation(jestMock)
render(<Advanced />)
let clearLogsButton
await waitFor(() => {
clearLogsButton = screen.getByTestId(/clear-logs/i)
fireEvent.click(clearLogsButton)
})
expect(clearLogsButton).toBeInTheDocument()
expect(jestMock).toHaveBeenCalled()
})
it('toggles proxy enabled', async () => {
render(<Advanced />)
let proxyToggle

View File

@ -2,11 +2,10 @@
import { useEffect, useState, useCallback, ChangeEvent } from 'react'
import { openExternalUrl, fs, AppConfiguration } from '@janhq/core'
import { openExternalUrl, AppConfiguration } from '@janhq/core'
import {
ScrollArea,
Button,
Switch,
Input,
Tooltip,
@ -180,24 +179,6 @@ const Advanced = () => {
setUseGpuIfPossible()
}, [readSettings, setGpuList, setGpuEnabled, setGpusInUse, setVulkanEnabled])
/**
* Clear logs
* @returns
*/
const clearLogs = async () => {
try {
await fs.rm(`file://logs`)
} catch (err) {
console.error('Error clearing logs: ', err)
}
toaster({
title: 'Logs cleared',
description: 'All logs have been cleared.',
type: 'success',
})
}
/**
* Handle GPU Change
* @param gpuId
@ -447,7 +428,7 @@ const Advanced = () => {
model performance (reload needed).
</p>
</div>
<div className="flex-sharink-0">
<div className="flex-shrink-0">
<Switch
checked={vulkanEnabled}
onChange={(e) => updateVulkanEnabled(e.target.checked)}
@ -542,25 +523,6 @@ const Advanced = () => {
</div>
)}
{/* Clear log */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="flex-shrink-0 space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Clear logs</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Clear all logs from Jan app.
</p>
</div>
<Button
data-testid="clear-logs"
theme="destructive"
onClick={clearLogs}
>
Clear
</Button>
</div>
{/* Factory Reset */}
<FactoryReset />
</div>

View File

@ -0,0 +1,82 @@
/**
* @jest-environment jsdom
*/
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import Privacy from '.'
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserverMock
global.window.core = {
api: {
getAppConfigurations: () => jest.fn(),
updateAppConfiguration: () => jest.fn(),
relaunch: () => jest.fn(),
},
}
const setSettingsMock = jest.fn()
// Mock useSettings hook
jest.mock('@/hooks/useSettings', () => ({
__esModule: true,
useSettings: () => ({
readSettings: () => ({
run_mode: 'gpu',
experimental: false,
proxy: false,
gpus: [{ name: 'gpu-1' }, { name: 'gpu-2' }],
gpus_in_use: ['0'],
quick_ask: false,
}),
setSettings: setSettingsMock,
}),
}))
import * as toast from '@/containers/Toast'
jest.mock('@/containers/Toast')
jest.mock('@janhq/core', () => ({
__esModule: true,
...jest.requireActual('@janhq/core'),
fs: {
rm: jest.fn(),
},
}))
// Simulate a full Privacy settings screen
// @ts-ignore
global.isMac = false
// @ts-ignore
global.isWindows = true
describe('Privacy', () => {
it('renders the component', async () => {
render(<Privacy />)
await waitFor(() => {
expect(screen.getByText('Clear logs')).toBeInTheDocument()
})
})
it('clears logs', async () => {
const jestMock = jest.fn()
jest.spyOn(toast, 'toaster').mockImplementation(jestMock)
render(<Privacy />)
let clearLogsButton
await waitFor(() => {
clearLogsButton = screen.getByTestId(/clear-logs/i)
fireEvent.click(clearLogsButton)
})
expect(clearLogsButton).toBeInTheDocument()
expect(jestMock).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,150 @@
import { fs } from '@janhq/core'
import { Button, Input, ScrollArea, Switch } from '@janhq/joi'
import { useAtom, useAtomValue } from 'jotai'
import { FolderOpenIcon } from 'lucide-react'
import posthog from 'posthog-js'
import { toaster } from '@/containers/Toast'
import { usePath } from '@/hooks/usePath'
import { janDataFolderPathAtom } from '@/helpers/atoms/AppConfig.atom'
import { productAnalyticAtom } from '@/helpers/atoms/Setting.atom'
const Privacy = () => {
/**
* Clear logs
* @returns
*/
const clearLogs = async () => {
try {
await fs.rm(`file://logs`)
} catch (err) {
console.error('Error clearing logs: ', err)
}
toaster({
title: 'Logs cleared',
description: 'All logs have been cleared.',
type: 'success',
})
}
const janDataFolderPath = useAtomValue(janDataFolderPathAtom)
const { onRevealInFinder } = usePath()
const [productAnalytic, setProductAnalytic] = useAtom(productAnalyticAtom)
return (
<ScrollArea className="h-full w-full px-4">
<div className="mb-4 mt-8 rounded-xl bg-[hsla(var(--tertiary-bg))] px-4 py-2 text-[hsla(var(--text-secondary))]">
<p>
We prioritize your control over your data. Learn more about our&nbsp;
<a
href="https://jan.ai/docs/privacy"
target="_blank"
className="text-[hsla(var(--app-link))]"
>
Privacy Policy.
</a>
</p>
<br />
<p>
To make Jan better, we need to understand how its used - but only if
you choose to help. You can change your Jan Analytics settings
anytime.
</p>
<br />
<p>
{`Your choice to opt-in or out doesn't change our core privacy promises:`}
</p>
<ul className="list-inside list-disc pl-4">
<li>Your chats are never read</li>
<li>No personal information is collected</li>
<li>No accounts or logins required</li>
<li>We dont access your files</li>
<li>Your chat history and settings stay on your device</li>
</ul>
</div>
<div className="block w-full py-4">
{/* Analytic */}
<div className="flex w-full flex-col justify-between gap-x-20 gap-y-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row sm:items-center">
<div className="space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Analytics</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
By opting in, you help us make Jan better by sharing anonymous
data, like feature usage and user counts. Your chats and personal
information are never collected.
</p>
</div>
<div className="flex-shrink-0">
<Switch
checked={productAnalytic}
onChange={(e) => {
if (e.target.checked) {
posthog.opt_in_capturing()
} else {
posthog.capture('user_opt_out', { timestamp: new Date() })
posthog.opt_out_capturing()
}
setProductAnalytic(e.target.checked)
}}
/>
</div>
</div>
{/* Logs */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Logs</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Open App Logs and Cortex Logs
</p>
</div>
<div className="flex items-center gap-x-3">
<div className="relative">
<Input
data-testid="jan-data-folder-input"
value={janDataFolderPath + '/logs'}
className="w-full pr-8 sm:w-[240px]"
disabled
/>
<FolderOpenIcon
size={16}
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 cursor-pointer"
onClick={() => onRevealInFinder('Logs')}
/>
</div>
</div>
</div>
{/* Clear log */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="flex-shrink-0 space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Clear logs</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Clear all logs from Jan app.
</p>
</div>
<Button
data-testid="clear-logs"
theme="destructive"
variant="soft"
onClick={clearLogs}
>
Clear
</Button>
</div>
</div>
</ScrollArea>
)
}
export default Privacy

View File

@ -6,6 +6,7 @@ import ExtensionCatalog from '@/screens/Settings/CoreExtensions'
import ExtensionSetting from '@/screens/Settings/ExtensionSetting'
import Hotkeys from '@/screens/Settings/Hotkeys'
import MyModels from '@/screens/Settings/MyModels'
import Privacy from '@/screens/Settings/Privacy'
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
@ -22,6 +23,9 @@ const SettingDetail = () => {
case 'Keyboard Shortcuts':
return <Hotkeys />
case 'Privacy':
return <Privacy />
case 'Advanced Settings':
return <Advanced />

View File

@ -15,6 +15,7 @@ export const SettingScreenList = [
'My Models',
'Appearance',
'Keyboard Shortcuts',
'Privacy',
'Advanced Settings',
'Extensions',
] as const

View File

@ -6,6 +6,8 @@ export {}
declare global {
declare const VERSION: string
declare const ANALYTICS_ID: string
declare const POSTHOG_KEY: string
declare const POSTHOG_HOST: string
declare const ANALYTICS_HOST: string
declare const API_BASE_URL: string
declare const isMac: boolean