refactor(i18n): about locales (#30336)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions

Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Stephen Zhou 2025-12-30 14:38:23 +08:00 committed by GitHub
parent 3505516e8e
commit 2399d00d86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 273 additions and 320 deletions

View File

@ -8,7 +8,7 @@ import { noop } from 'es-toolkit/compat'
import * as React from 'react' import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import Picker from '@/app/components/base/date-and-time-picker/date-picker' import Picker from '@/app/components/base/date-and-time-picker/date-picker'
import { useI18N } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { formatToLocalTime } from '@/utils/format' import { formatToLocalTime } from '@/utils/format'
@ -26,7 +26,7 @@ const DatePicker: FC<Props> = ({
onStartChange, onStartChange,
onEndChange, onEndChange,
}) => { }) => {
const { locale } = useI18N() const locale = useLocale()
const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => { const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => {
return ( return (

View File

@ -7,7 +7,7 @@ import dayjs from 'dayjs'
import * as React from 'react' import * as React from 'react'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { HourglassShape } from '@/app/components/base/icons/src/vender/other' import { HourglassShape } from '@/app/components/base/icons/src/vender/other'
import { useI18N } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { formatToLocalTime } from '@/utils/format' import { formatToLocalTime } from '@/utils/format'
import DatePicker from './date-picker' import DatePicker from './date-picker'
import RangeSelector from './range-selector' import RangeSelector from './range-selector'
@ -27,7 +27,7 @@ const TimeRangePicker: FC<Props> = ({
onSelect, onSelect,
queryDateFormat, queryDateFormat,
}) => { }) => {
const { locale } = useI18N() const locale = useLocale()
const [isCustomRange, setIsCustomRange] = useState(false) const [isCustomRange, setIsCustomRange] = useState(false)
const [start, setStart] = useState<Dayjs>(today) const [start, setStart] = useState<Dayjs>(today)

View File

@ -3,12 +3,12 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown' import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common' import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
export default function CheckCode() { export default function CheckCode() {
@ -19,7 +19,7 @@ export default function CheckCode() {
const token = decodeURIComponent(searchParams.get('token') as string) const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('') const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const verify = async () => { const verify = async () => {
try { try {

View File

@ -5,13 +5,13 @@ import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common' import { sendResetPasswordCode } from '@/service/common'
@ -22,7 +22,7 @@ export default function CheckCode() {
const router = useRouter() const router = useRouter()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const handleGetEMailVerificationCode = async () => { const handleGetEMailVerificationCode = async () => {
try { try {

View File

@ -4,12 +4,12 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown' import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context' import { useWebAppStore } from '@/context/web-app-context'
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common' import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
import { fetchAccessToken } from '@/service/share' import { fetchAccessToken } from '@/service/share'
@ -23,7 +23,7 @@ export default function CheckCode() {
const token = decodeURIComponent(searchParams.get('token') as string) const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('') const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const codeInputRef = useRef<HTMLInputElement>(null) const codeInputRef = useRef<HTMLInputElement>(null)
const redirectUrl = searchParams.get('redirect_url') const redirectUrl = searchParams.get('redirect_url')
const embeddedUserId = useWebAppStore(s => s.embeddedUserId) const embeddedUserId = useWebAppStore(s => s.embeddedUserId)

View File

@ -2,13 +2,12 @@ import { noop } from 'es-toolkit/compat'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { sendWebAppEMailLoginCode } from '@/service/common' import { sendWebAppEMailLoginCode } from '@/service/common'
export default function MailAndCodeAuth() { export default function MailAndCodeAuth() {
@ -18,7 +17,7 @@ export default function MailAndCodeAuth() {
const emailFromLink = decodeURIComponent(searchParams.get('email') || '') const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink) const [email, setEmail] = useState(emailFromLink)
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const handleGetEMailVerificationCode = async () => { const handleGetEMailVerificationCode = async () => {
try { try {

View File

@ -4,12 +4,11 @@ import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context' import { useWebAppStore } from '@/context/web-app-context'
import { webAppLogin } from '@/service/common' import { webAppLogin } from '@/service/common'
import { fetchAccessToken } from '@/service/share' import { fetchAccessToken } from '@/service/share'
@ -21,7 +20,7 @@ type MailAndPasswordAuthProps = {
export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) { export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18NContext) const locale = useLocale()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)

View File

@ -1,7 +1,8 @@
import type { Mock } from 'vitest'
import type { Locale } from '@/i18n-config' import type { Locale } from '@/i18n-config'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
import I18nContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import CSVDownload from './csv-downloader' import CSVDownload from './csv-downloader'
@ -17,17 +18,13 @@ vi.mock('react-papaparse', () => ({
})), })),
})) }))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en-US'),
}))
const renderWithLocale = (locale: Locale) => { const renderWithLocale = (locale: Locale) => {
return render( ;(useLocale as Mock).mockReturnValue(locale)
<I18nContext.Provider value={{ return render(<CSVDownload />)
locale,
i18n: {},
setLocaleOnClient: vi.fn().mockResolvedValue(undefined),
}}
>
<CSVDownload />
</I18nContext.Provider>,
)
} }
describe('CSVDownload', () => { describe('CSVDownload', () => {

View File

@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next'
import { import {
useCSVDownloader, useCSVDownloader,
} from 'react-papaparse' } from 'react-papaparse'
import { useContext } from 'use-context-selector'
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
import I18n from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
const CSV_TEMPLATE_QA_EN = [ const CSV_TEMPLATE_QA_EN = [
@ -24,7 +24,7 @@ const CSV_TEMPLATE_QA_CN = [
const CSVDownload: FC = () => { const CSVDownload: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader() const { CSVDownloader, Type } = useCSVDownloader()
const getTemplate = () => { const getTemplate = () => {

View File

@ -1,10 +1,11 @@
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import type { Mock } from 'vitest'
import type { AnnotationItemBasic } from '../type' import type { AnnotationItemBasic } from '../type'
import type { Locale } from '@/i18n-config' import type { Locale } from '@/i18n-config'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import * as React from 'react' import * as React from 'react'
import I18NContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import HeaderOptions from './index' import HeaderOptions from './index'
@ -163,12 +164,18 @@ vi.mock('@/app/components/billing/annotation-full', () => ({
default: () => <div data-testid="annotation-full" />, default: () => <div data-testid="annotation-full" />,
})) }))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => LanguagesSupported[0]),
}))
type HeaderOptionsProps = ComponentProps<typeof HeaderOptions> type HeaderOptionsProps = ComponentProps<typeof HeaderOptions>
const renderComponent = ( const renderComponent = (
props: Partial<HeaderOptionsProps> = {}, props: Partial<HeaderOptionsProps> = {},
locale: Locale = LanguagesSupported[0], locale: Locale = LanguagesSupported[0],
) => { ) => {
;(useLocale as Mock).mockReturnValue(locale)
const defaultProps: HeaderOptionsProps = { const defaultProps: HeaderOptionsProps = {
appId: 'test-app-id', appId: 'test-app-id',
onAdd: vi.fn(), onAdd: vi.fn(),
@ -177,17 +184,7 @@ const renderComponent = (
...props, ...props,
} }
return render( return render(<HeaderOptions {...defaultProps} />)
<I18NContext.Provider
value={{
locale,
i18n: {},
setLocaleOnClient: vi.fn(),
}}
>
<HeaderOptions {...defaultProps} />
</I18NContext.Provider>,
)
} }
const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => { const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => {
@ -440,20 +437,12 @@ describe('HeaderOptions', () => {
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1)) await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1))
view.rerender( view.rerender(
<I18NContext.Provider <HeaderOptions
value={{ appId="test-app-id"
locale: LanguagesSupported[0], onAdd={vi.fn()}
i18n: {}, onAdded={vi.fn()}
setLocaleOnClient: vi.fn(), controlUpdateList={1}
}} />,
>
<HeaderOptions
appId="test-app-id"
onAdd={vi.fn()}
onAdded={vi.fn()}
controlUpdateList={1}
/>
</I18NContext.Provider>,
) )
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2)) await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2))

View File

@ -13,15 +13,14 @@ import { useTranslation } from 'react-i18next'
import { import {
useCSVDownloader, useCSVDownloader,
} from 'react-papaparse' } from 'react-papaparse'
import { useContext } from 'use-context-selector'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import CustomPopover from '@/app/components/base/popover' import CustomPopover from '@/app/components/base/popover'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import { cn } from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Button from '../../../base/button' import Button from '../../../base/button'
import AddAnnotationModal from '../add-annotation-modal' import AddAnnotationModal from '../add-annotation-modal'
import BatchAddModal from '../batch-add-annotation-modal' import BatchAddModal from '../batch-add-annotation-modal'
@ -44,7 +43,7 @@ const HeaderOptions: FC<Props> = ({
controlUpdateList, controlUpdateList,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader() const { CSVDownloader, Type } = useCSVDownloader()
const [list, setList] = useState<AnnotationItemBasic[]>([]) const [list, setList] = useState<AnnotationItemBasic[]>([])
const annotationUnavailable = list.length === 0 const annotationUnavailable = list.length === 0

View File

@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import * as React from 'react' import * as React from 'react'
import { CollectionType } from '@/app/components/tools/types' import { CollectionType } from '@/app/components/tools/types'
import I18n from '@/context/i18n'
import SettingBuiltInTool from './setting-built-in-tool' import SettingBuiltInTool from './setting-built-in-tool'
const fetchModelToolList = vi.fn() const fetchModelToolList = vi.fn()
@ -56,6 +55,10 @@ vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({
ReadmeEntrance: ({ className }: { className?: string }) => <div className={className}>readme</div>, ReadmeEntrance: ({ className }: { className?: string }) => <div className={className}>readme</div>,
})) }))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en-US'),
}))
const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({ const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({
name: 'settingParam', name: 'settingParam',
label: { label: {
@ -129,18 +132,16 @@ const renderComponent = (props?: Partial<React.ComponentProps<typeof SettingBuil
const onSave = vi.fn() const onSave = vi.fn()
const onAuthorizationItemClick = vi.fn() const onAuthorizationItemClick = vi.fn()
const utils = render( const utils = render(
<I18n.Provider value={{ locale: 'en-US', i18n: {}, setLocaleOnClient: vi.fn() as any }}> <SettingBuiltInTool
<SettingBuiltInTool collection={baseCollection as any}
collection={baseCollection as any} toolName="search"
toolName="search" isModel
isModel setting={{ settingParam: 'value' }}
setting={{ settingParam: 'value' }} onHide={onHide}
onHide={onHide} onSave={onSave}
onSave={onSave} onAuthorizationItemClick={onAuthorizationItemClick}
onAuthorizationItemClick={onAuthorizationItemClick} {...props}
{...props} />,
/>
</I18n.Provider>,
) )
return { return {
...utils, ...utils,

View File

@ -9,7 +9,6 @@ import {
import * as React from 'react' import * as React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Drawer from '@/app/components/base/drawer' import Drawer from '@/app/components/base/drawer'
@ -26,7 +25,7 @@ import {
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { CollectionType } from '@/app/components/tools/types' import { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language' import { getLanguage } from '@/i18n-config/language'
import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools' import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@ -58,7 +57,7 @@ const SettingBuiltInTool: FC<Props> = ({
credentialId, credentialId,
onAuthorizationItemClick, onAuthorizationItemClick,
}) => { }) => {
const { locale } = useContext(I18n) const locale = useLocale()
const language = getLanguage(locale) const language = getLanguage(locale)
const { t } = useTranslation() const { t } = useTranslation()
const passedTools = (collection as ToolWithProvider).tools const passedTools = (collection as ToolWithProvider).tools

View File

@ -6,7 +6,6 @@ import type {
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/compat'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import EmojiPicker from '@/app/components/base/emoji-picker' import EmojiPicker from '@/app/components/base/emoji-picker'
@ -16,7 +15,7 @@ import Modal from '@/app/components/base/modal'
import { SimpleSelect } from '@/app/components/base/select' import { SimpleSelect } from '@/app/components/base/select'
import { useToastContext } from '@/app/components/base/toast' import { useToastContext } from '@/app/components/base/toast'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import I18n, { useDocLink } from '@/context/i18n' import { useDocLink, useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { useCodeBasedExtensions } from '@/service/use-common' import { useCodeBasedExtensions } from '@/service/use-common'
@ -41,7 +40,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
const { t } = useTranslation() const { t } = useTranslation()
const docLink = useDocLink() const docLink = useDocLink()
const { notify } = useToastContext() const { notify } = useToastContext()
const { locale } = useContext(I18n) const locale = useLocale()
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' }) const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
const [showEmojiPicker, setShowEmojiPicker] = useState(false) const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool') const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool')

View File

@ -6,13 +6,13 @@ import {
RiErrorWarningLine, RiErrorWarningLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useState } from 'react' import { useState } from 'react'
import { useContext } from 'use-context-selector'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import BlockIcon from '@/app/components/workflow/block-icon' import BlockIcon from '@/app/components/workflow/block-icon'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types'
import I18n from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
type Props = { type Props = {
@ -26,7 +26,7 @@ type Props = {
const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, observation, finalAnswer }) => { const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, observation, finalAnswer }) => {
const [collapseState, setCollapseState] = useState<boolean>(true) const [collapseState, setCollapseState] = useState<boolean>(true)
const { locale } = useContext(I18n) const locale = useLocale()
const toolName = isLLM ? 'LLM' : (toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')]) const toolName = isLLM ? 'LLM' : (toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')])
const getTime = (time: number) => { const getTime = (time: number) => {

View File

@ -1,10 +1,9 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { CodeBasedExtensionForm } from '@/models/common' import type { CodeBasedExtensionForm } from '@/models/common'
import type { ModerationConfig } from '@/models/debug' import type { ModerationConfig } from '@/models/debug'
import { useContext } from 'use-context-selector'
import { PortalSelect } from '@/app/components/base/select' import { PortalSelect } from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea' import Textarea from '@/app/components/base/textarea'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
type FormGenerationProps = { type FormGenerationProps = {
forms: CodeBasedExtensionForm[] forms: CodeBasedExtensionForm[]
@ -16,7 +15,7 @@ const FormGeneration: FC<FormGenerationProps> = ({
value, value,
onChange, onChange,
}) => { }) => {
const { locale } = useContext(I18n) const locale = useLocale()
const handleFormChange = (type: string, v: string) => { const handleFormChange = (type: string, v: string) => {
onChange({ ...value, [type]: v }) onChange({ ...value, [type]: v })

View File

@ -1,16 +1,14 @@
import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { OnFeaturesChange } from '@/app/components/base/features/types'
import { RiEqualizer2Line } from '@remixicon/react' import { RiEqualizer2Line } from '@remixicon/react'
import { produce } from 'immer' import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card' import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
import { FeatureEnum } from '@/app/components/base/features/types' import { FeatureEnum } from '@/app/components/base/features/types'
import { ContentModeration } from '@/app/components/base/icons/src/vender/features' import { ContentModeration } from '@/app/components/base/icons/src/vender/features'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useCodeBasedExtensions } from '@/service/use-common' import { useCodeBasedExtensions } from '@/service/use-common'
@ -25,7 +23,7 @@ const Moderation = ({
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { setShowModerationSettingModal } = useModalContext() const { setShowModerationSettingModal } = useModalContext()
const { locale } = useContext(I18n) const locale = useLocale()
const featuresStore = useFeaturesStore() const featuresStore = useFeaturesStore()
const moderation = useFeatures(s => s.features.moderation) const moderation = useFeatures(s => s.features.moderation)
const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation') const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation')

View File

@ -5,7 +5,6 @@ import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/compat'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
@ -15,7 +14,7 @@ import { useToastContext } from '@/app/components/base/toast'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import I18n, { useDocLink } from '@/context/i18n' import { useDocLink, useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { useCodeBasedExtensions, useModelProviders } from '@/service/use-common' import { useCodeBasedExtensions, useModelProviders } from '@/service/use-common'
@ -45,7 +44,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
const { t } = useTranslation() const { t } = useTranslation()
const docLink = useDocLink() const docLink = useDocLink()
const { notify } = useToastContext() const { notify } = useToastContext()
const { locale } = useContext(I18n) const locale = useLocale()
const { data: modelProviders, isPending: isLoading, refetch: refetchModelProviders } = useModelProviders() const { data: modelProviders, isPending: isLoading, refetch: refetchModelProviders } = useModelProviders()
const [localeData, setLocaleData] = useState<ModerationConfig>(data) const [localeData, setLocaleData] = useState<ModerationConfig>(data)
const { setShowAccountSettingModal } = useModalContext() const { setShowAccountSettingModal } = useModalContext()

View File

@ -1,13 +1,13 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { useI18N } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { usePipelineTemplateList } from '@/service/use-pipeline' import { usePipelineTemplateList } from '@/service/use-pipeline'
import CreateCard from './create-card' import CreateCard from './create-card'
import TemplateCard from './template-card' import TemplateCard from './template-card'
const BuiltInPipelineList = () => { const BuiltInPipelineList = () => {
const { locale } = useI18N() const locale = useLocale()
const language = useMemo(() => { const language = useMemo(() => {
if (['zh-Hans', 'ja-JP'].includes(locale)) if (['zh-Hans', 'ja-JP'].includes(locale))
return locale return locale

View File

@ -10,7 +10,7 @@ import SimplePieChart from '@/app/components/base/simple-pie-chart'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { upload } from '@/service/base' import { upload } from '@/service/base'
@ -40,7 +40,7 @@ const FileUploader = ({
}: IFileUploaderProps) => { }: IFileUploaderProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { notify } = useContext(ToastContext) const { notify } = useContext(ToastContext)
const { locale } = useContext(I18n) const locale = useLocale()
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null) const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null) const dragRef = useRef<HTMLDivElement>(null)

View File

@ -12,10 +12,8 @@ import {
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/compat'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
@ -38,7 +36,7 @@ import { useDefaultModel, useModelList, useModelListAndDefaultModelAndCurrentPro
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { FULL_DOC_PREVIEW_LENGTH, IS_CE_EDITION } from '@/config' import { FULL_DOC_PREVIEW_LENGTH, IS_CE_EDITION } from '@/config'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import I18n, { useDocLink } from '@/context/i18n' import { useDocLink, useLocale } from '@/context/i18n'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { DataSourceProvider } from '@/models/common' import { DataSourceProvider } from '@/models/common'
@ -151,7 +149,7 @@ const StepTwo = ({
}: StepTwoProps) => { }: StepTwoProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const docLink = useDocLink() const docLink = useDocLink()
const { locale } = useContext(I18n) const locale = useLocale()
const media = useBreakpoints() const media = useBreakpoints()
const isMobile = media === MediaType.mobile const isMobile = media === MediaType.mobile

View File

@ -11,7 +11,7 @@ import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/u
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon' import DocumentFileIcon from '@/app/components/datasets/common/document-file-icon'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { upload } from '@/service/base' import { upload } from '@/service/base'
@ -33,7 +33,7 @@ const LocalFile = ({
}: LocalFileProps) => { }: LocalFileProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { notify } = useContext(ToastContext) const { notify } = useContext(ToastContext)
const { locale } = useContext(I18n) const locale = useLocale()
const localFileList = useDataSourceStoreWithSelector(state => state.localFileList) const localFileList = useDataSourceStoreWithSelector(state => state.localFileList)
const dataSourceStore = useDataSourceStore() const dataSourceStore = useDataSourceStore()
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)

View File

@ -5,9 +5,8 @@ import { useTranslation } from 'react-i18next'
import { import {
useCSVDownloader, useCSVDownloader,
} from 'react-papaparse' } from 'react-papaparse'
import { useContext } from 'use-context-selector'
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general' import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { ChunkingMode } from '@/models/datasets' import { ChunkingMode } from '@/models/datasets'
@ -34,7 +33,7 @@ const CSV_TEMPLATE_CN = [
const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => { const CSVDownload: FC<{ docForm: ChunkingMode }> = ({ docForm }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader() const { CSVDownloader, Type } = useCSVDownloader()
const getTemplate = () => { const getTemplate = () => {

View File

@ -2,8 +2,7 @@
import { RiCloseLine, RiListUnordered } from '@remixicon/react' import { RiCloseLine, RiListUnordered } from '@remixicon/react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector' import { useLocale } from '@/context/i18n'
import I18n from '@/context/i18n'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { AppModeEnum, Theme } from '@/types/app' import { AppModeEnum, Theme } from '@/types/app'
@ -26,7 +25,7 @@ type IDocProps = {
} }
const Doc = ({ appDetail }: IDocProps) => { const Doc = ({ appDetail }: IDocProps) => {
const { locale } = useContext(I18n) const locale = useLocale()
const { t } = useTranslation() const { t } = useTranslation()
const [toc, setToc] = useState<Array<{ href: string, text: string }>>([]) const [toc, setToc] = useState<Array<{ href: string, text: string }>>([])
const [isTocExpanded, setIsTocExpanded] = useState(false) const [isTocExpanded, setIsTocExpanded] = useState(false)

View File

@ -8,7 +8,9 @@ import { useContext } from 'use-context-selector'
import { SimpleSelect } from '@/app/components/base/select' import { SimpleSelect } from '@/app/components/base/select'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import I18n from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
import { languages } from '@/i18n-config/language' import { languages } from '@/i18n-config/language'
import { updateUserProfile } from '@/service/common' import { updateUserProfile } from '@/service/common'
import { timezones } from '@/utils/timezone' import { timezones } from '@/utils/timezone'
@ -18,7 +20,7 @@ const titleClassName = `
` `
export default function LanguagePage() { export default function LanguagePage() {
const { locale, setLocaleOnClient } = useContext(I18n) const locale = useLocale()
const { userProfile, mutateUserProfile } = useAppContext() const { userProfile, mutateUserProfile } = useAppContext()
const { notify } = useContext(ToastContext) const { notify } = useContext(ToastContext)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)

View File

@ -3,7 +3,6 @@ import type { InvitationResult } from '@/models/common'
import { RiPencilLine, RiUserAddLine } from '@remixicon/react' import { RiPencilLine, RiUserAddLine } from '@remixicon/react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Avatar from '@/app/components/base/avatar' import Avatar from '@/app/components/base/avatar'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
@ -12,7 +11,7 @@ import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn' import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
@ -34,7 +33,7 @@ const MembersPage = () => {
dataset_operator: t('members.datasetOperator', { ns: 'common' }), dataset_operator: t('members.datasetOperator', { ns: 'common' }),
normal: t('members.normal', { ns: 'common' }), normal: t('members.normal', { ns: 'common' }),
} }
const { locale } = useContext(I18n) const locale = useLocale()
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext() const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext()
const { data, refetch } = useMembers() const { data, refetch } = useMembers()

View File

@ -12,7 +12,7 @@ import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal' import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useProviderContextSelector } from '@/context/provider-context' import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common' import { inviteMember } from '@/service/common'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@ -47,7 +47,7 @@ const InviteModal = ({
setIsLimitExceeded(limited && (used > licenseLimit.workspace_members.limit)) setIsLimitExceeded(limited && (used > licenseLimit.workspace_members.limit))
}, [licenseLimit, emails]) }, [licenseLimit, emails])
const { locale } = useContext(I18n) const locale = useLocale()
const [role, setRole] = useState<RoleKey>('normal') const [role, setRole] = useState<RoleKey>('normal')
const [isSubmitting, { const [isSubmitting, {

View File

@ -1,6 +1,6 @@
import type { Mock } from 'vitest' import type { Mock } from 'vitest'
import { renderHook } from '@testing-library/react' import { renderHook } from '@testing-library/react'
import { useContext } from 'use-context-selector' import { useLocale } from '@/context/i18n'
import { useLanguage } from './hooks' import { useLanguage } from './hooks'
vi.mock('@tanstack/react-query', () => ({ vi.mock('@tanstack/react-query', () => ({
@ -36,8 +36,7 @@ vi.mock('@/service/use-common', () => ({
// mock context hooks // mock context hooks
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
__esModule: true, useLocale: vi.fn(() => 'en-US'),
default: vi.fn(),
})) }))
vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/provider-context', () => ({
@ -72,27 +71,20 @@ afterAll(() => {
describe('useLanguage', () => { describe('useLanguage', () => {
it('should replace hyphen with underscore in locale', () => { it('should replace hyphen with underscore in locale', () => {
(useContext as Mock).mockReturnValue({ ;(useLocale as Mock).mockReturnValue('en-US')
locale: 'en-US',
})
const { result } = renderHook(() => useLanguage()) const { result } = renderHook(() => useLanguage())
expect(result.current).toBe('en_US') expect(result.current).toBe('en_US')
}) })
it('should return locale as is if no hyphen exists', () => { it('should return locale as is if no hyphen exists', () => {
(useContext as Mock).mockReturnValue({ ;(useLocale as Mock).mockReturnValue('enUS')
locale: 'enUS',
})
const { result } = renderHook(() => useLanguage()) const { result } = renderHook(() => useLanguage())
expect(result.current).toBe('enUS') expect(result.current).toBe('enUS')
}) })
it('should handle multiple hyphens', () => { it('should handle multiple hyphens', () => {
// Mock the I18n context return value ;(useLocale as Mock).mockReturnValue('zh-Hans-CN')
(useContext as Mock).mockReturnValue({
locale: 'zh-Hans-CN',
})
const { result } = renderHook(() => useLanguage()) const { result } = renderHook(() => useLanguage())
expect(result.current).toBe('zh_Hans-CN') expect(result.current).toBe('zh_Hans-CN')

View File

@ -16,14 +16,13 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react' } from 'react'
import { useContext } from 'use-context-selector'
import { import {
useMarketplacePlugins, useMarketplacePlugins,
useMarketplacePluginsByCollectionId, useMarketplacePluginsByCollectionId,
} from '@/app/components/plugins/marketplace/hooks' } from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context' import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { import {
@ -70,7 +69,7 @@ export const useSystemDefaultModelAndModelList: UseDefaultModelAndModelList = (
} }
export const useLanguage = () => { export const useLanguage = () => {
const { locale } = useContext(I18n) const locale = useLocale()
return locale.replace('-', '_') return locale.replace('-', '_')
} }

View File

@ -3,9 +3,10 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { Locale } from '@/i18n-config' import type { Locale } from '@/i18n-config'
import { usePrefetchQuery } from '@tanstack/react-query' import { usePrefetchQuery } from '@tanstack/react-query'
import { useHydrateAtoms } from 'jotai/utils'
import * as React from 'react' import * as React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import I18NContext from '@/context/i18n' import { localeAtom } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config' import { setLocaleOnClient } from '@/i18n-config'
import { getSystemFeatures } from '@/service/common' import { getSystemFeatures } from '@/service/common'
import Loading from './base/loading' import Loading from './base/loading'
@ -18,6 +19,7 @@ const I18n: FC<II18nProps> = ({
locale, locale,
children, children,
}) => { }) => {
useHydrateAtoms([[localeAtom, locale]])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
usePrefetchQuery({ usePrefetchQuery({
@ -35,14 +37,9 @@ const I18n: FC<II18nProps> = ({
return <div className="flex h-screen w-screen items-center justify-center"><Loading type="app" /></div> return <div className="flex h-screen w-screen items-center justify-center"><Loading type="app" /></div>
return ( return (
<I18NContext.Provider value={{ <>
locale,
i18n: {},
setLocaleOnClient,
}}
>
{children} {children}
</I18NContext.Provider> </>
) )
} }
export default React.memo(I18n) export default React.memo(I18n)

View File

@ -46,7 +46,6 @@ vi.mock('../marketplace/hooks', () => ({
// Mock useGetLanguage context // Mock useGetLanguage context
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US', useGetLanguage: () => 'en-US',
useI18N: () => ({ locale: 'en-US' }),
})) }))
// Mock useTheme hook // Mock useTheme hook

View File

@ -1,9 +1,6 @@
/* eslint-disable dify-i18n/require-ns-option */ /* eslint-disable dify-i18n/require-ns-option */
import type { Locale } from '@/i18n-config' import type { Locale } from '@/i18n-config'
import { import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
getLocaleOnServer,
getTranslation as translate,
} from '@/i18n-config/server'
type DescriptionProps = { type DescriptionProps = {
locale?: Locale locale?: Locale
@ -12,8 +9,8 @@ const Description = async ({
locale: localeFromProps, locale: localeFromProps,
}: DescriptionProps) => { }: DescriptionProps) => {
const localeDefault = await getLocaleOnServer() const localeDefault = await getLocaleOnServer()
const { t } = await translate(localeFromProps || localeDefault, 'plugin') const { t } = await getTranslation(localeFromProps || localeDefault, 'plugin')
const { t: tCommon } = await translate(localeFromProps || localeDefault, 'common') const { t: tCommon } = await getTranslation(localeFromProps || localeDefault, 'common')
const isZhHans = localeFromProps === 'zh-Hans' const isZhHans = localeFromProps === 'zh-Hans'
return ( return (

View File

@ -191,11 +191,9 @@ vi.mock('next-themes', () => ({
}), }),
})) }))
// Mock useI18N context // Mock useLocale context
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
useI18N: () => ({ useLocale: () => 'en-US',
locale: 'en-US',
}),
})) }))
// Mock i18n-config/language // Mock i18n-config/language

View File

@ -12,7 +12,7 @@ import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import { useTags } from '@/app/components/plugins/hooks' import { useTags } from '@/app/components/plugins/hooks'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks' import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
import { useI18N } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils' import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
type CardWrapperProps = { type CardWrapperProps = {
@ -31,7 +31,7 @@ const CardWrapperComponent = ({
setTrue: showInstallFromMarketplace, setTrue: showInstallFromMarketplace,
setFalse: hideInstallFromMarketplace, setFalse: hideInstallFromMarketplace,
}] = useBoolean(false) }] = useBoolean(false)
const { locale: localeFromLocale } = useI18N() const localeFromLocale = useLocale()
const { getTagLabel } = useTags(t) const { getTagLabel } = useTags(t)
// Memoize marketplace link params to prevent unnecessary re-renders // Memoize marketplace link params to prevent unnecessary re-renders

View File

@ -49,11 +49,9 @@ vi.mock('../context', () => ({
useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues), useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues),
})) }))
// Mock useI18N context // Mock useLocale context
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
useI18N: () => ({ useLocale: () => 'en-US',
locale: 'en-US',
}),
})) }))
// Mock next-themes // Mock next-themes

View File

@ -26,7 +26,7 @@ import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-v
import { API_PREFIX } from '@/config' import { API_PREFIX } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage, useI18N } from '@/context/i18n' import { useGetLanguage, useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
@ -67,7 +67,7 @@ const DetailHeader = ({
const { theme } = useTheme() const { theme } = useTheme()
const locale = useGetLanguage() const locale = useGetLanguage()
const { locale: currentLocale } = useI18N() const currentLocale = useLocale()
const { checkForUpdates, fetchReleases } = useGitHubReleases() const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext() const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext() const { refreshModelProviders } = useProviderContext()

View File

@ -29,7 +29,6 @@ vi.mock('../marketplace/hooks', () => ({
// Mock useGetLanguage context // Mock useGetLanguage context
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US', useGetLanguage: () => 'en-US',
useI18N: () => ({ locale: 'en-US' }),
})) }))
// Mock useTheme hook // Mock useTheme hook

View File

@ -6,11 +6,10 @@ import {
} from '@remixicon/react' } from '@remixicon/react'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import { getDocsUrl } from '@/app/components/plugins/utils' import { getDocsUrl } from '@/app/components/plugins/utils'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useDebugKey } from '@/service/use-plugins' import { useDebugKey } from '@/service/use-plugins'
import KeyValueItem from '../base/key-value-item' import KeyValueItem from '../base/key-value-item'
@ -18,7 +17,7 @@ const i18nPrefix = 'debugInfo'
const DebugInfo: FC = () => { const DebugInfo: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const { data: info, isLoading } = useDebugKey() const { data: info, isLoading } = useDebugKey()
// info.key likes 4580bdb7-b878-471c-a8a4-bfd760263a53 mask the middle part using *. // info.key likes 4580bdb7-b878-471c-a8a4-bfd760263a53 mask the middle part using *.

View File

@ -11,7 +11,6 @@ import { noop } from 'es-toolkit/compat'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import TabSlider from '@/app/components/base/tab-slider' import TabSlider from '@/app/components/base/tab-slider'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
@ -19,7 +18,7 @@ import ReferenceSettingModal from '@/app/components/plugins/reference-setting-mo
import { getDocsUrl } from '@/app/components/plugins/utils' import { getDocsUrl } from '@/app/components/plugins/utils'
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { usePluginInstallation } from '@/hooks/use-query-params' import { usePluginInstallation } from '@/hooks/use-query-params'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
@ -48,7 +47,7 @@ const PluginPage = ({
marketplace, marketplace,
}: PluginPageProps) => { }: PluginPageProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
useDocumentTitle(t('metadata.title', { ns: 'plugin' })) useDocumentTitle(t('metadata.title', { ns: 'plugin' }))
// Use nuqs hook for installation state // Use nuqs hook for installation state

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { getPluginLinkInMarketplace } from '@/app/components/plugins/marketplace/utils' import { getPluginLinkInMarketplace } from '@/app/components/plugins/marketplace/utils'
import { useI18N } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useRenderI18nObject } from '@/hooks/use-i18n' import { useRenderI18nObject } from '@/hooks/use-i18n'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import Badge from '../base/badge' import Badge from '../base/badge'
@ -36,7 +36,7 @@ const ProviderCardComponent: FC<Props> = ({
setFalse: hideInstallFromMarketplace, setFalse: hideInstallFromMarketplace,
}] = useBoolean(false) }] = useBoolean(false)
const { org, label } = payload const { org, label } = payload
const { locale } = useI18N() const locale = useLocale()
// Memoize the marketplace link params to prevent unnecessary re-renders // Memoize the marketplace link params to prevent unnecessary re-renders
const marketplaceLinkParams = useMemo(() => ({ language: locale, theme }), [locale, theme]) const marketplaceLinkParams = useMemo(() => ({ language: locale, theme }), [locale, theme])

View File

@ -51,7 +51,6 @@ vi.mock('react-i18next', async (importOriginal) => {
// Mock useGetLanguage context // Mock useGetLanguage context
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US', useGetLanguage: () => 'en-US',
useI18N: () => ({ locale: 'en-US' }),
})) }))
// Mock app context for useGetIcon // Mock app context for useGetIcon

View File

@ -1,13 +1,17 @@
import type { CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types' import type { CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AuthType } from '@/app/components/tools/types' import { AuthType } from '@/app/components/tools/types'
import I18n from '@/context/i18n'
import { testAPIAvailable } from '@/service/tools' import { testAPIAvailable } from '@/service/tools'
import TestApi from './test-api' import TestApi from './test-api'
vi.mock('@/service/tools', () => ({ vi.mock('@/service/tools', () => ({
testAPIAvailable: vi.fn(), testAPIAvailable: vi.fn(),
})) }))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en-US'),
}))
const testAPIAvailableMock = vi.mocked(testAPIAvailable) const testAPIAvailableMock = vi.mocked(testAPIAvailable)
describe('TestApi', () => { describe('TestApi', () => {
@ -40,19 +44,12 @@ describe('TestApi', () => {
} }
const renderTestApi = () => { const renderTestApi = () => {
const providerValue = {
locale: 'en-US',
i18n: {},
setLocaleOnClient: vi.fn(),
}
return render( return render(
<I18n.Provider value={providerValue as any}> <TestApi
<TestApi customCollection={customCollection}
customCollection={customCollection} tool={tool}
tool={tool} onHide={vi.fn()}
onHide={vi.fn()} />,
/>
</I18n.Provider>,
) )
} }

View File

@ -5,12 +5,11 @@ import { RiSettings2Line } from '@remixicon/react'
import * as React from 'react' import * as React from 'react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Drawer from '@/app/components/base/drawer-plus' import Drawer from '@/app/components/base/drawer-plus'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { AuthType } from '@/app/components/tools/types' import { AuthType } from '@/app/components/tools/types'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language' import { getLanguage } from '@/i18n-config/language'
import { testAPIAvailable } from '@/service/tools' import { testAPIAvailable } from '@/service/tools'
import ConfigCredentials from './config-credentials' import ConfigCredentials from './config-credentials'
@ -29,7 +28,7 @@ const TestApi: FC<Props> = ({
onHide, onHide,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const language = getLanguage(locale) const language = getLanguage(locale)
const [credentialsModalShow, setCredentialsModalShow] = useState(false) const [credentialsModalShow, setCredentialsModalShow] = useState(false)
const [tempCredential, setTempCredential] = React.useState<Credential>(customCollection.credentials) const [tempCredential, setTempCredential] = React.useState<Credential>(customCollection.credentials)

View File

@ -7,9 +7,8 @@ import {
} from '@remixicon/react' } from '@remixicon/react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language' import { getLanguage } from '@/i18n-config/language'
import { useCreateMCP } from '@/service/use-tools' import { useCreateMCP } from '@/service/use-tools'
import MCPModal from './modal' import MCPModal from './modal'
@ -20,7 +19,7 @@ type Props = {
const NewMCPCard = ({ handleCreate }: Props) => { const NewMCPCard = ({ handleCreate }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const language = getLanguage(locale) const language = getLanguage(locale)
const { isCurrentWorkspaceManager } = useAppContext() const { isCurrentWorkspaceManager } = useAppContext()

View File

@ -2,9 +2,8 @@
import type { Tool } from '@/app/components/tools/types' import type { Tool } from '@/app/components/tools/types'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language' import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@ -15,7 +14,7 @@ type Props = {
const MCPToolItem = ({ const MCPToolItem = ({
tool, tool,
}: Props) => { }: Props) => {
const { locale } = useContext(I18n) const locale = useLocale()
const language = getLanguage(locale) const language = getLanguage(locale)
const { t } = useTranslation() const { t } = useTranslation()

View File

@ -7,11 +7,10 @@ import {
} from '@remixicon/react' } from '@remixicon/react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal' import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import I18n, { useDocLink } from '@/context/i18n' import { useDocLink, useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language' import { getLanguage } from '@/i18n-config/language'
import { createCustomCollection } from '@/service/tools' import { createCustomCollection } from '@/service/tools'
@ -21,7 +20,7 @@ type Props = {
const Contribute = ({ onRefreshData }: Props) => { const Contribute = ({ onRefreshData }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const language = getLanguage(locale) const language = getLanguage(locale)
const { isCurrentWorkspaceManager } = useAppContext() const { isCurrentWorkspaceManager } = useAppContext()

View File

@ -6,7 +6,6 @@ import {
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm' import Confirm from '@/app/components/base/confirm'
@ -24,7 +23,7 @@ import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-m
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import WorkflowToolModal from '@/app/components/tools/workflow-tool' import WorkflowToolModal from '@/app/components/tools/workflow-tool'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
@ -60,7 +59,7 @@ const ProviderDetail = ({
onRefreshData, onRefreshData,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const language = getLanguage(locale) const language = getLanguage(locale)
const needAuth = collection.allow_delete || collection.type === CollectionType.model const needAuth = collection.allow_delete || collection.type === CollectionType.model

View File

@ -2,9 +2,8 @@
import type { Collection, Tool } from '../types' import type { Collection, Tool } from '../types'
import * as React from 'react' import * as React from 'react'
import { useState } from 'react' import { useState } from 'react'
import { useContext } from 'use-context-selector'
import SettingBuiltInTool from '@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool' import SettingBuiltInTool from '@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language' import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@ -23,7 +22,7 @@ const ToolItem = ({
isBuiltIn, isBuiltIn,
isModel, isModel,
}: Props) => { }: Props) => {
const { locale } = useContext(I18n) const locale = useLocale()
const language = getLanguage(locale) const language = getLanguage(locale)
const [showDetail, setShowDetail] = useState(false) const [showDetail, setShowDetail] = useState(false)

View File

@ -1,20 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { useContext } from 'use-context-selector'
import I18NContext from '@/context/i18n'
export type II18NHocProps = {
children: ReactNode
}
const withI18N = (Component: any) => {
return (props: any) => {
const { i18n } = useContext(I18NContext)
return (
<Component {...props} i18n={i18n} />
)
}
}
export default withI18N

View File

@ -4,9 +4,8 @@ import type { Plugin } from '@/app/components/plugins/types.ts'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
@ -27,7 +26,7 @@ const Item: FC<Props> = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
const { locale } = useContext(I18n) const locale = useLocale()
const getLocalizedText = (obj: Record<string, string> | undefined) => const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' obj?.[locale] || obj?.['en-US'] || obj?.en_US || ''
const [isShowInstallModal, { const [isShowInstallModal, {

View File

@ -3,9 +3,8 @@ import type { Plugin } from '@/app/components/plugins/types'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import BlockIcon from '../../block-icon' import BlockIcon from '../../block-icon'
import { BlockEnum } from '../../types' import { BlockEnum } from '../../types'
@ -17,7 +16,7 @@ const UninstalledItem = ({
payload, payload,
}: UninstalledItemProps) => { }: UninstalledItemProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const getLocalizedText = (obj: Record<string, string> | undefined) => const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj?.[locale] || obj?.['en-US'] || obj?.en_US || '' obj?.[locale] || obj?.['en-US'] || obj?.en_US || ''

View File

@ -3,10 +3,9 @@ import type { DocExtractorNodeType } from './types'
import type { NodePanelProps } from '@/app/components/workflow/types' import type { NodePanelProps } from '@/app/components/workflow/types'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Field from '@/app/components/workflow/nodes/_base/components/field' import Field from '@/app/components/workflow/nodes/_base/components/field'
import { BlockEnum } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { useFileSupportTypes } from '@/service/use-common' import { useFileSupportTypes } from '@/service/use-common'
import OutputVars, { VarItem } from '../_base/components/output-vars' import OutputVars, { VarItem } from '../_base/components/output-vars'
@ -22,7 +21,7 @@ const Panel: FC<NodePanelProps<DocExtractorNodeType>> = ({
data, data,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18n) const locale = useLocale()
const link = useNodeHelpLink(BlockEnum.DocExtractor) const link = useNodeHelpLink(BlockEnum.DocExtractor)
const { data: supportFileTypesResponse } = useFileSupportTypes() const { data: supportFileTypesResponse } = useFileSupportTypes()
const supportTypes = supportFileTypesResponse?.allowed_extensions || [] const supportTypes = supportFileTypesResponse?.allowed_extensions || []

View File

@ -3,12 +3,11 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown' import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { sendResetPasswordCode, verifyResetPasswordCode } from '@/service/common' import { sendResetPasswordCode, verifyResetPasswordCode } from '@/service/common'
export default function CheckCode() { export default function CheckCode() {
@ -19,7 +18,7 @@ export default function CheckCode() {
const token = decodeURIComponent(searchParams.get('token') as string) const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('') const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const verify = async () => { const verify = async () => {
try { try {

View File

@ -5,12 +5,11 @@ import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common' import { sendResetPasswordCode } from '@/service/common'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown'
@ -22,7 +21,7 @@ export default function CheckCode() {
const router = useRouter() const router = useRouter()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const handleGetEMailVerificationCode = async () => { const handleGetEMailVerificationCode = async () => {
try { try {

View File

@ -1,12 +1,11 @@
'use client' 'use client'
import type { Locale } from '@/i18n-config' import type { Locale } from '@/i18n-config'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import * as React from 'react'
import { useContext } from 'use-context-selector'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import LocaleSigninSelect from '@/app/components/base/select/locale-signin' import LocaleSigninSelect from '@/app/components/base/select/locale-signin'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
import { languages } from '@/i18n-config/language' import { languages } from '@/i18n-config/language'
// Avoid rendering the logo and theme selector on the server // Avoid rendering the logo and theme selector on the server
@ -20,7 +19,7 @@ const ThemeSelector = dynamic(() => import('@/app/components/base/theme-selector
}) })
const Header = () => { const Header = () => {
const { locale, setLocaleOnClient } = useContext(I18n) const locale = useLocale()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
return ( return (

View File

@ -4,13 +4,13 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown' import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
import { encryptVerificationCode } from '@/utils/encryption' import { encryptVerificationCode } from '@/utils/encryption'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect' import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
@ -25,7 +25,7 @@ export default function CheckCode() {
const language = i18n.language const language = i18n.language
const [code, setVerifyCode] = useState('') const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const codeInputRef = useRef<HTMLInputElement>(null) const codeInputRef = useRef<HTMLInputElement>(null)
const verify = async () => { const verify = async () => {

View File

@ -2,13 +2,12 @@ import type { FormEvent } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { sendEMailLoginCode } from '@/service/common' import { sendEMailLoginCode } from '@/service/common'
type MailAndCodeAuthProps = { type MailAndCodeAuthProps = {
@ -22,7 +21,7 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) {
const emailFromLink = decodeURIComponent(searchParams.get('email') || '') const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink) const [email, setEmail] = useState(emailFromLink)
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const handleGetEMailVerificationCode = async () => { const handleGetEMailVerificationCode = async () => {
try { try {

View File

@ -4,13 +4,12 @@ import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { trackEvent } from '@/app/components/base/amplitude' import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { login } from '@/service/common' import { login } from '@/service/common'
import { encryptPassword } from '@/utils/encryption' import { encryptPassword } from '@/utils/encryption'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect' import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
@ -23,7 +22,7 @@ type MailAndPasswordAuthProps = {
export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegistration: _allowRegistration }: MailAndPasswordAuthProps) { export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegistration: _allowRegistration }: MailAndPasswordAuthProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { locale } = useContext(I18NContext) const locale = useLocale()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)

View File

@ -6,14 +6,14 @@ import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { SimpleSelect } from '@/app/components/base/select' import { SimpleSelect } from '@/app/components/base/select'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import I18n, { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
import { languages, LanguagesSupported } from '@/i18n-config/language' import { languages, LanguagesSupported } from '@/i18n-config/language'
import { activateMember } from '@/service/common' import { activateMember } from '@/service/common'
import { useInvitationCheck } from '@/service/use-common' import { useInvitationCheck } from '@/service/use-common'
@ -27,7 +27,6 @@ export default function InviteSettingsPage() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const token = decodeURIComponent(searchParams.get('invite_token') as string) const token = decodeURIComponent(searchParams.get('invite_token') as string)
const { setLocaleOnClient } = useContext(I18n)
const [name, setName] = useState('') const [name, setName] = useState('')
const [language, setLanguage] = useState(LanguagesSupported[0]) const [language, setLanguage] = useState(LanguagesSupported[0])
const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles') const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles')
@ -65,7 +64,7 @@ export default function InviteSettingsPage() {
catch { catch {
recheck() recheck()
} }
}, [language, name, recheck, setLocaleOnClient, timezone, token, router, t]) }, [language, name, recheck, timezone, token, router, t])
if (!checkRes) if (!checkRes)
return <Loading /> return <Loading />

View File

@ -4,12 +4,11 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown' import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useMailValidity, useSendMail } from '@/service/use-common' import { useMailValidity, useSendMail } from '@/service/use-common'
export default function CheckCode() { export default function CheckCode() {
@ -20,7 +19,7 @@ export default function CheckCode() {
const [token, setToken] = useState(decodeURIComponent(searchParams.get('token') as string)) const [token, setToken] = useState(decodeURIComponent(searchParams.get('token') as string))
const [code, setVerifyCode] = useState('') const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false) const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext) const locale = useLocale()
const { mutateAsync: submitMail } = useSendMail() const { mutateAsync: submitMail } = useSendMail()
const { mutateAsync: verifyCode } = useMailValidity() const { mutateAsync: verifyCode } = useMailValidity()

View File

@ -4,14 +4,13 @@ import { noop } from 'es-toolkit/compat'
import Link from 'next/link' import Link from 'next/link'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import Split from '@/app/signin/split' import Split from '@/app/signin/split'
import { emailRegex } from '@/config' import { emailRegex } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import I18n from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useSendMail } from '@/service/use-common' import { useSendMail } from '@/service/use-common'
type Props = { type Props = {
@ -22,7 +21,7 @@ export default function Form({
}: Props) { }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const { locale } = useContext(I18n) const locale = useLocale()
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
const { mutateAsync: submitMail, isPending } = useSendMail() const { mutateAsync: submitMail, isPending } = useSendMail()

View File

@ -1,33 +1,19 @@
import type { Locale } from '@/i18n-config' import type { Locale } from '@/i18n-config/language'
import { noop } from 'es-toolkit/compat' import { atom, useAtomValue } from 'jotai'
import {
createContext,
useContext,
} from 'use-context-selector'
import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language' import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language'
type II18NContext = { export const localeAtom = atom<Locale>('en-US')
locale: Locale export const useLocale = () => {
i18n: Record<string, any> return useAtomValue(localeAtom)
setLocaleOnClient: (_lang: Locale, _reloadPage?: boolean) => Promise<void>
} }
const I18NContext = createContext<II18NContext>({
locale: 'en-US',
i18n: {},
setLocaleOnClient: async (_lang: Locale, _reloadPage?: boolean) => {
noop()
},
})
export const useI18N = () => useContext(I18NContext)
export const useGetLanguage = () => { export const useGetLanguage = () => {
const { locale } = useI18N() const locale = useLocale()
return getLanguage(locale) return getLanguage(locale)
} }
export const useGetPricingPageLanguage = () => { export const useGetPricingPageLanguage = () => {
const { locale } = useI18N() const locale = useLocale()
return getPricingPageLanguage(locale) return getPricingPageLanguage(locale)
} }
@ -36,7 +22,7 @@ export const defaultDocBaseUrl = 'https://docs.dify.ai'
export const useDocLink = (baseUrl?: string): ((path?: string, pathMap?: { [index: string]: string }) => string) => { export const useDocLink = (baseUrl?: string): ((path?: string, pathMap?: { [index: string]: string }) => string) => {
let baseDocUrl = baseUrl || defaultDocBaseUrl let baseDocUrl = baseUrl || defaultDocBaseUrl
baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl
const { locale } = useI18N() const locale = useLocale()
const docLanguage = getDocLanguage(locale) const docLanguage = getDocLanguage(locale)
return (path?: string, pathMap?: { [index: string]: string }): string => { return (path?: string, pathMap?: { [index: string]: string }): string => {
const pathUrl = path || '' const pathUrl = path || ''
@ -45,4 +31,3 @@ export const useDocLink = (baseUrl?: string): ((path?: string, pathMap?: { [inde
return `${baseDocUrl}/${docLanguage}/${targetPath}` return `${baseDocUrl}/${docLanguage}/${targetPath}`
} }
} }
export default I18NContext

View File

@ -14,15 +14,13 @@ import type { Mock } from 'vitest'
*/ */
import { renderHook } from '@testing-library/react' import { renderHook } from '@testing-library/react'
// Import after mock to get the mocked version // Import after mock to get the mocked version
import { useI18N } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { useFormatTimeFromNow } from './use-format-time-from-now' import { useFormatTimeFromNow } from './use-format-time-from-now'
// Mock the i18n context // Mock the i18n context
vi.mock('@/context/i18n', () => ({ vi.mock('@/context/i18n', () => ({
useI18N: vi.fn(() => ({ useLocale: vi.fn(() => 'en-US'),
locale: 'en-US',
})),
})) }))
describe('useFormatTimeFromNow', () => { describe('useFormatTimeFromNow', () => {
@ -47,7 +45,7 @@ describe('useFormatTimeFromNow', () => {
* Should return human-readable relative time strings * Should return human-readable relative time strings
*/ */
it('should format time from now in English', () => { it('should format time from now in English', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -65,7 +63,7 @@ describe('useFormatTimeFromNow', () => {
* Very recent timestamps should show seconds * Very recent timestamps should show seconds
*/ */
it('should format very recent times', () => { it('should format very recent times', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -81,7 +79,7 @@ describe('useFormatTimeFromNow', () => {
* Should handle day-level granularity * Should handle day-level granularity
*/ */
it('should format times from days ago', () => { it('should format times from days ago', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -98,7 +96,7 @@ describe('useFormatTimeFromNow', () => {
* dayjs fromNow also supports future times (e.g., "in 2 hours") * dayjs fromNow also supports future times (e.g., "in 2 hours")
*/ */
it('should format future times', () => { it('should format future times', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -117,7 +115,7 @@ describe('useFormatTimeFromNow', () => {
* Should use Chinese characters for time units * Should use Chinese characters for time units
*/ */
it('should format time in Chinese (Simplified)', () => { it('should format time in Chinese (Simplified)', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'zh-Hans' }) ;(useLocale as Mock).mockReturnValue('zh-Hans')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -134,7 +132,7 @@ describe('useFormatTimeFromNow', () => {
* Should use Spanish words for relative time * Should use Spanish words for relative time
*/ */
it('should format time in Spanish', () => { it('should format time in Spanish', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'es-ES' }) ;(useLocale as Mock).mockReturnValue('es-ES')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -151,7 +149,7 @@ describe('useFormatTimeFromNow', () => {
* Should use French words for relative time * Should use French words for relative time
*/ */
it('should format time in French', () => { it('should format time in French', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'fr-FR' }) ;(useLocale as Mock).mockReturnValue('fr-FR')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -168,7 +166,7 @@ describe('useFormatTimeFromNow', () => {
* Should use Japanese characters * Should use Japanese characters
*/ */
it('should format time in Japanese', () => { it('should format time in Japanese', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'ja-JP' }) ;(useLocale as Mock).mockReturnValue('ja-JP')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -185,7 +183,7 @@ describe('useFormatTimeFromNow', () => {
* Should use pt-br locale mapping * Should use pt-br locale mapping
*/ */
it('should format time in Portuguese (Brazil)', () => { it('should format time in Portuguese (Brazil)', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'pt-BR' }) ;(useLocale as Mock).mockReturnValue('pt-BR')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -202,7 +200,7 @@ describe('useFormatTimeFromNow', () => {
* Unknown locales should default to English * Unknown locales should default to English
*/ */
it('should fallback to English for unsupported locale', () => { it('should fallback to English for unsupported locale', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'xx-XX' as any }) ;(useLocale as Mock).mockReturnValue('xx-XX' as any)
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -222,7 +220,7 @@ describe('useFormatTimeFromNow', () => {
* Should format as a very old date * Should format as a very old date
*/ */
it('should handle timestamp 0', () => { it('should handle timestamp 0', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -238,7 +236,7 @@ describe('useFormatTimeFromNow', () => {
* Should handle dates far in the future * Should handle dates far in the future
*/ */
it('should handle very large timestamps', () => { it('should handle very large timestamps', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -260,12 +258,12 @@ describe('useFormatTimeFromNow', () => {
const oneHourAgo = now - (60 * 60 * 1000) const oneHourAgo = now - (60 * 60 * 1000)
// First render with English // First render with English
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
rerender() rerender()
const englishResult = result.current.formatTimeFromNow(oneHourAgo) const englishResult = result.current.formatTimeFromNow(oneHourAgo)
// Second render with Spanish // Second render with Spanish
;(useI18N as Mock).mockReturnValue({ locale: 'es-ES' }) ;(useLocale as Mock).mockReturnValue('es-ES')
rerender() rerender()
const spanishResult = result.current.formatTimeFromNow(oneHourAgo) const spanishResult = result.current.formatTimeFromNow(oneHourAgo)
@ -280,7 +278,7 @@ describe('useFormatTimeFromNow', () => {
* dayjs should automatically choose the appropriate unit * dayjs should automatically choose the appropriate unit
*/ */
it('should use appropriate time units for different durations', () => { it('should use appropriate time units for different durations', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
@ -342,7 +340,7 @@ describe('useFormatTimeFromNow', () => {
const oneHourAgo = now - (60 * 60 * 1000) const oneHourAgo = now - (60 * 60 * 1000)
locales.forEach((locale) => { locales.forEach((locale) => {
;(useI18N as Mock).mockReturnValue({ locale }) ;(useLocale as Mock).mockReturnValue(locale)
const { result } = renderHook(() => useFormatTimeFromNow()) const { result } = renderHook(() => useFormatTimeFromNow())
const formatted = result.current.formatTimeFromNow(oneHourAgo) const formatted = result.current.formatTimeFromNow(oneHourAgo)
@ -360,7 +358,7 @@ describe('useFormatTimeFromNow', () => {
* The formatTimeFromNow function should be memoized with useCallback * The formatTimeFromNow function should be memoized with useCallback
*/ */
it('should memoize formatTimeFromNow function', () => { it('should memoize formatTimeFromNow function', () => {
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
const { result, rerender } = renderHook(() => useFormatTimeFromNow()) const { result, rerender } = renderHook(() => useFormatTimeFromNow())
@ -379,11 +377,11 @@ describe('useFormatTimeFromNow', () => {
it('should create new function when locale changes', () => { it('should create new function when locale changes', () => {
const { result, rerender } = renderHook(() => useFormatTimeFromNow()) const { result, rerender } = renderHook(() => useFormatTimeFromNow())
;(useI18N as Mock).mockReturnValue({ locale: 'en-US' }) ;(useLocale as Mock).mockReturnValue('en-US')
rerender() rerender()
const englishFunction = result.current.formatTimeFromNow const englishFunction = result.current.formatTimeFromNow
;(useI18N as Mock).mockReturnValue({ locale: 'es-ES' }) ;(useLocale as Mock).mockReturnValue('es-ES')
rerender() rerender()
const spanishFunction = result.current.formatTimeFromNow const spanishFunction = result.current.formatTimeFromNow

View File

@ -1,7 +1,7 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useI18N } from '@/context/i18n' import { useLocale } from '@/context/i18n'
import { localeMap } from '@/i18n-config/language' import { localeMap } from '@/i18n-config/language'
import 'dayjs/locale/de' import 'dayjs/locale/de'
import 'dayjs/locale/es' import 'dayjs/locale/es'
@ -27,7 +27,7 @@ import 'dayjs/locale/zh-tw'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
export const useFormatTimeFromNow = () => { export const useFormatTimeFromNow = () => {
const { locale } = useI18N() const locale = useLocale()
const formatTimeFromNow = useCallback((time: number) => { const formatTimeFromNow = useCallback((time: number) => {
const dayjsLocale = localeMap[locale] ?? 'en' const dayjsLocale = localeMap[locale] ?? 'en'
return dayjs(time).locale(dayjsLocale).fromNow() return dayjs(time).locale(dayjsLocale).fromNow()

View File

@ -7,7 +7,7 @@
- useTranslation - useTranslation
- useGetLanguage - useGetLanguage
- useI18N - useLocale
- useRenderI18nObject - useRenderI18nObject
## impl ## impl
@ -46,6 +46,6 @@
## TODO ## TODO
- [ ] ts docs for useGetLanguage - [ ] ts docs for useGetLanguage
- [ ] ts docs for useI18N - [ ] ts docs for useLocale
- [ ] client docs for i18n - [ ] client docs for i18n
- [ ] server docs for i18n - [ ] server docs for i18n

View File

@ -2,7 +2,6 @@
import type { Locale } from '.' import type { Locale } from '.'
import { camelCase, kebabCase } from 'es-toolkit/compat' import { camelCase, kebabCase } from 'es-toolkit/compat'
import i18n from 'i18next' import i18n from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import appAnnotation from '../i18n/en-US/app-annotation.json' import appAnnotation from '../i18n/en-US/app-annotation.json'
import appApi from '../i18n/en-US/app-api.json' import appApi from '../i18n/en-US/app-api.json'

View File

@ -1,3 +1,4 @@
import type { i18n as I18nInstance } from 'i18next'
import type { Locale } from '.' import type { Locale } from '.'
import type { NamespaceCamelCase, NamespaceKebabCase } from './i18next-config' import type { NamespaceCamelCase, NamespaceKebabCase } from './i18next-config'
import { match } from '@formatjs/intl-localematcher' import { match } from '@formatjs/intl-localematcher'
@ -7,29 +8,39 @@ import resourcesToBackend from 'i18next-resources-to-backend'
import Negotiator from 'negotiator' import Negotiator from 'negotiator'
import { cookies, headers } from 'next/headers' import { cookies, headers } from 'next/headers'
import { initReactI18next } from 'react-i18next/initReactI18next' import { initReactI18next } from 'react-i18next/initReactI18next'
import serverOnlyContext from '@/utils/server-only-context'
import { i18n } from '.' import { i18n } from '.'
// https://locize.com/blog/next-13-app-dir-i18n/ const [getLocaleCache, setLocaleCache] = serverOnlyContext<Locale | null>(null)
const initI18next = async (lng: Locale, ns: NamespaceKebabCase) => { const [getI18nInstance, setI18nInstance] = serverOnlyContext<I18nInstance | null>(null)
const i18nInstance = createInstance()
await i18nInstance const getOrCreateI18next = async (lng: Locale) => {
let instance = getI18nInstance()
if (instance)
return instance
instance = createInstance()
await instance
.use(initReactI18next) .use(initReactI18next)
.use(resourcesToBackend((language: Locale, namespace: NamespaceKebabCase) => { .use(resourcesToBackend((language: Locale, namespace: NamespaceKebabCase) => {
return import(`../i18n/${language}/${namespace}.json`) return import(`../i18n/${language}/${namespace}.json`)
})) }))
.init({ .init({
lng: lng === 'zh-Hans' ? 'zh-Hans' : lng, lng,
ns,
defaultNS: ns,
fallbackLng: 'en-US', fallbackLng: 'en-US',
keySeparator: false, keySeparator: false,
}) })
return i18nInstance setI18nInstance(instance)
return instance
} }
export async function getTranslation(lng: Locale, ns: NamespaceKebabCase) { export async function getTranslation(lng: Locale, ns: NamespaceKebabCase) {
const camelNs = camelCase(ns) as NamespaceCamelCase const camelNs = camelCase(ns) as NamespaceCamelCase
const i18nextInstance = await initI18next(lng, ns) const i18nextInstance = await getOrCreateI18next(lng)
if (!i18nextInstance.hasLoadedNamespace(camelNs))
await i18nextInstance.loadNamespaces(camelNs)
return { return {
t: i18nextInstance.getFixedT(lng, camelNs), t: i18nextInstance.getFixedT(lng, camelNs),
i18n: i18nextInstance, i18n: i18nextInstance,
@ -37,6 +48,10 @@ export async function getTranslation(lng: Locale, ns: NamespaceKebabCase) {
} }
export const getLocaleOnServer = async (): Promise<Locale> => { export const getLocaleOnServer = async (): Promise<Locale> => {
const cached = getLocaleCache()
if (cached)
return cached
const locales: string[] = i18n.locales const locales: string[] = i18n.locales
let languages: string[] | undefined let languages: string[] | undefined
@ -58,5 +73,6 @@ export const getLocaleOnServer = async (): Promise<Locale> => {
// match locale // match locale
const matchedLocale = match(languages, locales, i18n.defaultLocale) as Locale const matchedLocale = match(languages, locales, i18n.defaultLocale) as Locale
setLocaleCache(matchedLocale)
return matchedLocale return matchedLocale
} }

View File

@ -92,6 +92,7 @@
"i18next": "^25.7.3", "i18next": "^25.7.3",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"immer": "^11.1.0", "immer": "^11.1.0",
"jotai": "^2.16.1",
"js-audio-recorder": "^1.0.7", "js-audio-recorder": "^1.0.7",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

28
web/pnpm-lock.yaml generated
View File

@ -192,6 +192,9 @@ importers:
immer: immer:
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.1.0 version: 11.1.0
jotai:
specifier: ^2.16.1
version: 2.16.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.7)(react@19.2.3)
js-audio-recorder: js-audio-recorder:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7 version: 1.0.7
@ -6207,6 +6210,24 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
jotai@2.16.1:
resolution: {integrity: sha512-vrHcAbo3P7Br37C8Bv6JshMtlKMPqqmx0DDREtTjT4nf3QChDrYdbH+4ik/9V0cXA57dK28RkJ5dctYvavcIlg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
'@babel/template': '>=7.0.0'
'@types/react': ~19.2.7
react: '>=17.0.0'
peerDependenciesMeta:
'@babel/core':
optional: true
'@babel/template':
optional: true
'@types/react':
optional: true
react:
optional: true
js-audio-recorder@1.0.7: js-audio-recorder@1.0.7:
resolution: {integrity: sha512-JiDODCElVHGrFyjGYwYyNi7zCbKk9va9C77w+zCPMmi4C6ix7zsX2h3ddHugmo4dOTOTCym9++b/wVW9nC0IaA==} resolution: {integrity: sha512-JiDODCElVHGrFyjGYwYyNi7zCbKk9va9C77w+zCPMmi4C6ix7zsX2h3ddHugmo4dOTOTCym9++b/wVW9nC0IaA==}
@ -15331,6 +15352,13 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
jotai@2.16.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.7)(react@19.2.3):
optionalDependencies:
'@babel/core': 7.28.5
'@babel/template': 7.27.2
'@types/react': 19.2.7
react: 19.2.3
js-audio-recorder@1.0.7: {} js-audio-recorder@1.0.7: {}
js-base64@3.7.8: {} js-base64@3.7.8: {}

View File

@ -0,0 +1,15 @@
// credit: https://github.com/manvalls/server-only-context/blob/main/src/index.ts
import { cache } from 'react'
export default <T>(defaultValue: T): [() => T, (v: T) => void] => {
const getRef = cache(() => ({ current: defaultValue }))
const getValue = (): T => getRef().current
const setValue = (value: T) => {
getRef().current = value
}
return [getValue, setValue]
}