diff --git a/app/containers/MessageComposer/hooks/useAutocomplete.ts b/app/containers/MessageComposer/hooks/useAutocomplete.ts index 906fe8d631e..249a4302f63 100644 --- a/app/containers/MessageComposer/hooks/useAutocomplete.ts +++ b/app/containers/MessageComposer/hooks/useAutocomplete.ts @@ -139,17 +139,47 @@ export const useAutocomplete = ({ if (type === '/') { const db = database.active; const commandsCollection = db.get('slash_commands'); + const appTranslationsCollection = db.get('app_translations'); const likeString = sanitizeLikeString(text); - const commands = await ( - await commandsCollection.query(Q.where('id', Q.like(`${likeString}%`))).fetch() - ).map(command => ({ - id: command.id, - title: command.id, - subtitle: command.description, - type - })); - setItems(commands); + const rawCommands = await commandsCollection.query(Q.where('id', Q.like(`${likeString}%`))).fetch(); + + const commands = await Promise.all( + rawCommands.map(async command => { + let subtitle = ''; + const { description } = command; + + if (!description) { + // no description at all — leave empty + subtitle = ''; + } else if (command.appId) { + const appLang = I18n.currentLocale().split('-')[0]; + + // app translation key — look up in WatermelonDB + const translationRecords = await appTranslationsCollection + .query(Q.where('key', description), Q.where('language', appLang)) + .fetch(); + + if (translationRecords.length > 0) { + subtitle = (translationRecords[0] as any).value; + } else { + // not in DB yet — fallback to readable form + subtitle = description.split('.').pop()?.replace(/_/g, ' ') ?? description; + } + } else { + subtitle = description; + } + + return { + id: command.id, + title: command.id, + subtitle, + type + }; + }) + ); + + setItems(commands); if (commands.length > 0) { updateAutocompleteVisible(true); accessibilityFocusOnInput(); diff --git a/app/definitions/rest/v1/appTranslations.ts b/app/definitions/rest/v1/appTranslations.ts new file mode 100644 index 00000000000..c3d527babcb --- /dev/null +++ b/app/definitions/rest/v1/appTranslations.ts @@ -0,0 +1,8 @@ +export type AppsTranslationsEndpoints = { + 'apps.translations': { + GET: (params: { language?: string }) => { + language: string; + translations: { [key: string]: string }; + }; + }; +}; diff --git a/app/definitions/rest/v1/index.ts b/app/definitions/rest/v1/index.ts index 0642359a1bb..d0fa6fb1fc8 100644 --- a/app/definitions/rest/v1/index.ts +++ b/app/definitions/rest/v1/index.ts @@ -21,6 +21,7 @@ import { type PushEndpoints } from './push'; import { type DirectoryEndpoint } from './directory'; import { type AutoTranslateEndpoints } from './autotranslate'; import { type ModerationEndpoints } from './moderation'; +import { type AppsTranslationsEndpoints } from './appTranslations'; export type Endpoints = ChannelsEndpoints & ChatEndpoints & @@ -44,4 +45,5 @@ export type Endpoints = ChannelsEndpoints & PushEndpoints & DirectoryEndpoint & AutoTranslateEndpoints & - ModerationEndpoints; + ModerationEndpoints & + AppsTranslationsEndpoints; diff --git a/app/lib/database/index.ts b/app/lib/database/index.ts index 0d26e7f65c8..90e564f7f4d 100644 --- a/app/lib/database/index.ts +++ b/app/lib/database/index.ts @@ -25,6 +25,7 @@ import appSchema from './schema/app'; import migrations from './model/migrations'; import serversMigrations from './model/servers/migrations'; import { type TAppDatabase, type TServerDatabase } from './interfaces'; +import AppTranslation from './model/AppTranslation'; if (__DEV__) { console.log(appGroupPath); @@ -60,7 +61,8 @@ export const getDatabase = (database = ''): Database => { Role, Permission, SlashCommand, - User + User, + AppTranslation ] }); }; diff --git a/app/lib/database/model/AppTranslation.ts b/app/lib/database/model/AppTranslation.ts new file mode 100644 index 00000000000..fa7741091b3 --- /dev/null +++ b/app/lib/database/model/AppTranslation.ts @@ -0,0 +1,10 @@ +import { Model } from '@nozbe/watermelondb'; +import { field } from '@nozbe/watermelondb/decorators'; + +export default class AppTranslation extends Model { + static table = 'app_translations'; + + @field('key') key!: string; + @field('value') value!: string; + @field('language') language!: string; +} diff --git a/app/lib/database/model/migrations.js b/app/lib/database/model/migrations.js index 17cecfe4e2b..f088f17fe1a 100644 --- a/app/lib/database/model/migrations.js +++ b/app/lib/database/model/migrations.js @@ -345,6 +345,19 @@ export default schemaMigrations({ ] }) ] + }, + { + toVersion: 29, + steps: [ + createTable({ + name: 'app_translations', + columns: [ + { name: 'key', type: 'string', isIndexed: true }, + { name: 'value', type: 'string' }, + { name: 'language', type: 'string', isIndexed: true } + ] + }) + ] } ] }); diff --git a/app/lib/database/schema/app.js b/app/lib/database/schema/app.js index d5b8df00b5c..8ccbe434e1b 100644 --- a/app/lib/database/schema/app.js +++ b/app/lib/database/schema/app.js @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export default appSchema({ - version: 28, + version: 29, tables: [ tableSchema({ name: 'subscriptions', @@ -285,6 +285,14 @@ export default appSchema({ { name: 'username', type: 'string', isIndexed: true }, { name: 'avatar_etag', type: 'string', isOptional: true } ] + }), + tableSchema({ + name: 'app_translations', + columns: [ + { name: 'key', type: 'string', isIndexed: true }, + { name: 'value', type: 'string' }, + { name: 'language', type: 'string', isIndexed: true } + ] }) ] }); diff --git a/app/lib/methods/getAppTranslations.ts b/app/lib/methods/getAppTranslations.ts new file mode 100644 index 00000000000..71538742075 --- /dev/null +++ b/app/lib/methods/getAppTranslations.ts @@ -0,0 +1,41 @@ +import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import { Q } from '@nozbe/watermelondb'; + +import database from '../database'; +import log from './helpers/log'; +import protectedFunction from './helpers/protectedFunction'; +import sdk from '../services/sdk'; + +export async function getAppTranslations(language = 'en'): Promise { + try { + const db = database.active; + + const result = await sdk.get('apps.translations', { language }); + + if (!result?.success || !result.translations) { + return; + } + + await db.write(async () => { + const collection = db.get('app_translations'); + + const existing = await collection.query(Q.where('language', result.language)).fetch(); + const toDelete = existing.map((r: any) => r.prepareDestroyPermanently()); + + const toCreate = Object.entries(result.translations).map(([key, value]) => + collection.prepareCreate( + protectedFunction((r: any) => { + r._raw = sanitizedRaw({ id: `${result.language}_${key}` }, collection.schema); + r.key = key; + r.value = value as string; + r.language = result.language; + }) + ) + ); + + await db.batch(...toDelete, ...toCreate); + }); + } catch (e) { + log(e); + } +} diff --git a/app/sagas/login.js b/app/sagas/login.js index e88740ed6ff..d7db9e176ab 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -39,6 +39,7 @@ import appNavigation from '../lib/navigation/appNavigation'; import { showActionSheetRef } from '../containers/ActionSheet'; import { SupportedVersionsWarning } from '../containers/SupportedVersions'; import { isIOS } from '../lib/methods/helpers'; +import { getAppTranslations } from '../lib/methods/getAppTranslations'; const getServer = state => state.server.server; const loginWithPasswordCall = args => loginWithPassword(args); @@ -184,6 +185,15 @@ const fetchSlashCommandsFork = function* fetchSlashCommandsFork() { } }; +const fetchAppTranslationsFork = function* fetchAppTranslationsFork(userLanguage) { + try { + const appLang = (userLanguage || I18n.currentLocale()).split('-')[0]; + yield getAppTranslations(appLang); + } catch (e) { + log(e); + } +}; + const registerPushTokenFork = function* registerPushTokenFork() { try { yield registerPushToken(); @@ -250,6 +260,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { yield fork(fetchPermissionsFork); yield fork(fetchCustomEmojisFork); yield fork(fetchRolesFork); + yield fork(fetchAppTranslationsFork, user.language); yield fork(fetchSlashCommandsFork); yield fork(registerPushTokenFork); yield fork(fetchUsersPresenceFork); diff --git a/app/views/LanguageView/index.tsx b/app/views/LanguageView/index.tsx index 8aec1f66cb4..a53d25e926f 100644 --- a/app/views/LanguageView/index.tsx +++ b/app/views/LanguageView/index.tsx @@ -19,6 +19,7 @@ import { type SettingsStackParamList } from '../../stacks/types'; import { showErrorAlert } from '../../lib/methods/helpers/info'; import log, { events, logEvent } from '../../lib/methods/helpers/log'; import { saveUserPreferences } from '../../lib/services/restApi'; +import { getAppTranslations } from '../../lib/methods/getAppTranslations'; const LanguageView = () => { const { languageDefault, id } = useAppSelector(state => ({ @@ -81,6 +82,9 @@ const LanguageView = () => { logEvent(events.LANG_SET_LANGUAGE_F); } }); + + const appLang = (params.language || 'en').split('-')[0]; + await getAppTranslations(appLang); } catch (e) { logEvent(events.LANG_SET_LANGUAGE_F); showErrorAlert(I18n.t('There_was_an_error_while_action', { action: I18n.t('saving_preferences') }));