feat: Rename to Toju and add translation
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped

This commit is contained in:
2026-06-05 17:13:03 +02:00
parent 8ecfc9a1fe
commit ee293d7daf
301 changed files with 8247 additions and 2218 deletions

View File

@@ -1,3 +1,4 @@
import translationsEn from '../../../../../../public/i18n/en.json';
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
import { resolveCallNotificationAction } from '../../logic/call-notification.rules';
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
@@ -6,6 +7,18 @@ import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPl
const INCOMING_CALL_CHANNEL_ID = 'toju-incoming-call';
const ACTIVE_CALL_CHANNEL_ID = 'toju-active-call';
function mobileLabel(key: string): string {
const value = key.split('.').reduce<unknown>((current, part) => {
if (current && typeof current === 'object') {
return (current as Record<string, unknown>)[part];
}
return undefined;
}, translationsEn);
return typeof value === 'string' ? value : key;
}
/** Capacitor local + push notification bridge with action buttons for in-call controls. */
export class CapacitorMobileNotificationsAdapter implements MobileNotificationAdapter {
private actionHandler: ((input: { callId: string; intent: CallNotificationActionIntent }) => void) | null = null;
@@ -21,7 +34,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
await LocalNotifications.createChannel({
id: INCOMING_CALL_CHANNEL_ID,
name: 'Incoming calls',
name: mobileLabel('mobile.notifications.incomingCallsChannel'),
importance: 5,
visibility: 1,
sound: 'call.wav'
@@ -29,7 +42,7 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
await LocalNotifications.createChannel({
id: ACTIVE_CALL_CHANNEL_ID,
name: 'Active calls',
name: mobileLabel('mobile.notifications.activeCallsChannel'),
importance: 4,
visibility: 1
});
@@ -38,11 +51,17 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
types: [
{
id: 'INCOMING_CALL_ACTIONS',
actions: [{ id: 'answer', title: 'Answer' }, { id: 'hangup', title: 'Decline' }]
actions: [
{ id: 'answer', title: mobileLabel('mobile.notifications.answer') },
{ id: 'hangup', title: mobileLabel('mobile.notifications.decline') }
]
},
{
id: 'ACTIVE_CALL_ACTIONS',
actions: [{ id: 'mute', title: 'Mute' }, { id: 'hangup', title: 'Hang up' }]
actions: [
{ id: 'mute', title: mobileLabel('mobile.notifications.mute') },
{ id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') }
]
}
]
});
@@ -148,4 +167,4 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
this.actionHandler = handler;
}
}
}

View File

@@ -4,6 +4,7 @@ import {
it
} from 'vitest';
import translationsEn from '../../../../../public/i18n/en.json';
import {
buildMobileUpdateState,
createInitialMobileUpdateState,
@@ -13,9 +14,28 @@ import {
} from './mobile-app-update.rules';
import type { MobileAppUpdateInfoSnapshot } from './mobile-app-update.rules';
function instant(key: string, params?: Record<string, string | number>): string {
const value = key.split('.').reduce<unknown>((current, part) => {
if (current && typeof current === 'object') {
return (current as Record<string, unknown>)[part];
}
return undefined;
}, translationsEn);
if (typeof value !== 'string') {
return key;
}
return Object.entries(params ?? {}).reduce(
(message, [paramKey, paramValue]) => message.replace(`{{${paramKey}}}`, String(paramValue)),
value
);
}
describe('mobile-app-update.rules', () => {
it('creates an idle unsupported state for non-native shells', () => {
const state = createInitialMobileUpdateState({ isSupported: false });
const state = createInitialMobileUpdateState({ isSupported: false, translate: instant });
expect(state.status).toBe('unsupported');
expect(state.isSupported).toBe(false);
@@ -40,7 +60,8 @@ describe('mobile-app-update.rules', () => {
};
const state = buildMobileUpdateState(snapshot, {
isSupported: true,
lastCheckedAt: 1_700_000_000_000
lastCheckedAt: 1_700_000_000_000,
translate: instant
});
expect(state.status).toBe('update-available');
@@ -61,16 +82,16 @@ describe('mobile-app-update.rules', () => {
platform: 'ios',
updateAvailability: 'update-not-available'
};
const state = buildMobileUpdateState(snapshot, { isSupported: true });
const state = buildMobileUpdateState(snapshot, { isSupported: true, translate: instant });
expect(state.status).toBe('up-to-date');
expect(state.statusMessage).toContain('up to date');
});
it('maps errors to a friendly status message', () => {
expect(resolveMobileUpdateStatusMessage('error', new Error('Required app information could not be fetched')))
expect(resolveMobileUpdateStatusMessage('error', new Error('Required app information could not be fetched'), null, instant))
.toContain('store');
expect(getMobileUpdateStatusLabel('update-available')).toBe('Update available');
expect(getMobileUpdateStatusLabel('update-available', instant)).toBe('Update available');
});
});

View File

@@ -1,3 +1,5 @@
export type AppTranslateFn = (key: string, params?: Record<string, string | number>) => string;
export type MobileUpdateAvailability = 'unknown' | 'update-available' | 'update-not-available' | 'checking';
export type MobileUpdateStatus =
@@ -33,8 +35,10 @@ export interface MobileUpdateState {
}
export function createInitialMobileUpdateState(
options: { isSupported: boolean; currentVersion?: string } = { isSupported: false }
options: { isSupported: boolean; currentVersion?: string; translate?: AppTranslateFn } = { isSupported: false }
): MobileUpdateState {
const translate = options.translate ?? identityTranslate;
return {
availableVersion: null,
currentVersion: options.currentVersion ?? '0.0.0',
@@ -45,8 +49,8 @@ export function createInitialMobileUpdateState(
platform: null,
status: options.isSupported ? 'idle' : 'unsupported',
statusMessage: options.isSupported
? 'Waiting for the first store update check.'
: 'Store updates are only available in the packaged Android or iOS app.'
? translate('mobile.update.messages.waitingForCheck')
: translate('mobile.update.messages.unsupported')
};
}
@@ -72,8 +76,10 @@ export function buildMobileUpdateState(
lastCheckedAt?: number | null;
statusOverride?: MobileUpdateStatus;
statusMessage?: string | null;
translate?: AppTranslateFn;
}
): MobileUpdateState {
const translate = options.translate ?? identityTranslate;
const status = options.statusOverride ?? mapUpdateAvailabilityToStatus(snapshot.updateAvailability);
return {
@@ -85,67 +91,83 @@ export function buildMobileUpdateState(
lastCheckedAt: options.lastCheckedAt ?? null,
platform: snapshot.platform,
status,
statusMessage: options.statusMessage ?? resolveMobileUpdateStatusMessage(status, null, snapshot)
statusMessage: options.statusMessage ?? resolveMobileUpdateStatusMessage(status, null, snapshot, translate)
};
}
export function resolveMobileUpdateStatusMessage(
status: MobileUpdateStatus,
error: unknown = null,
snapshot: MobileAppUpdateInfoSnapshot | null = null
snapshot: MobileAppUpdateInfoSnapshot | null = null,
translate: AppTranslateFn = identityTranslate
): string | null {
if (status === 'error') {
const message = error instanceof Error ? error.message : String(error ?? '');
if (message.includes('Required app information could not be fetched')) {
return 'The app store could not return release information. Confirm the app is published and try again.';
return translate('mobile.update.messages.storeInfoFailed');
}
return message.trim() || 'Unable to check for mobile app updates.';
return message.trim() || translate('mobile.update.messages.checkFailed');
}
if (status === 'unsupported') {
return 'Store updates are only available in the packaged Android or iOS app.';
return translate('mobile.update.messages.unsupported');
}
if (status === 'checking') {
return 'Checking the app store for a newer release...';
return translate('mobile.update.messages.checking');
}
if (status === 'downloading') {
return 'Downloading the update from the app store...';
return translate('mobile.update.messages.downloading');
}
if (status === 'update-available' && snapshot?.availableVersion) {
return `MetoYou ${snapshot.availableVersion} is available in the app store.`;
return translate('mobile.update.messages.updateAvailable', { version: snapshot.availableVersion });
}
if (status === 'up-to-date' && snapshot?.currentVersion) {
return `MetoYou ${snapshot.currentVersion} is up to date.`;
return translate('mobile.update.messages.upToDate', { version: snapshot.currentVersion });
}
if (status === 'idle') {
return 'Waiting for the first store update check.';
return translate('mobile.update.messages.waitingForCheck');
}
return null;
}
export function getMobileUpdateStatusLabel(status: MobileUpdateStatus): string {
export function getMobileUpdateStatusLabel(
status: MobileUpdateStatus,
translate: AppTranslateFn = identityTranslate
): string {
switch (status) {
case 'checking':
return 'Checking';
return translate('mobile.update.statusLabels.checking');
case 'downloading':
return 'Downloading';
return translate('mobile.update.statusLabels.downloading');
case 'update-available':
return 'Update available';
return translate('mobile.update.statusLabels.updateAvailable');
case 'up-to-date':
return 'Up to date';
return translate('mobile.update.statusLabels.upToDate');
case 'unsupported':
return 'Unsupported';
return translate('mobile.update.statusLabels.unsupported');
case 'error':
return 'Error';
return translate('mobile.update.statusLabels.error');
default:
return 'Idle';
return translate('mobile.update.statusLabels.idle');
}
}
function identityTranslate(key: string, params?: Record<string, string | number>): string {
let value = key;
if (params) {
for (const [paramKey, paramValue] of Object.entries(params)) {
value = value.replace(`{{${paramKey}}}`, String(paramValue));
}
}
return value;
}

View File

@@ -8,12 +8,14 @@ import {
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../core/platform/viewport.service';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../core/i18n/app-i18n.testing';
import { MobileAppUpdateService } from './mobile-app-update.service';
import { MobilePlatformService } from './mobile-platform.service';
function createService(isMobile: boolean): MobileAppUpdateService {
const injector = Injector.create({
providers: [
...provideAppI18nForTests(),
MobileAppUpdateService,
MobilePlatformService,
{
@@ -29,6 +31,8 @@ function createService(isMobile: boolean): MobileAppUpdateService {
]
});
initializeAppI18nForTests(injector);
return runInInjectionContext(injector, () => injector.get(MobileAppUpdateService));
}

View File

@@ -6,6 +6,7 @@ import {
import { WebMobileAppUpdateAdapter } from '../adapters/web/web-mobile-app-update.adapter';
import type { MobileAppUpdateAdapter } from '../contracts/mobile.contracts';
import { AppI18nService } from '../../../core/i18n';
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
import {
buildMobileUpdateState,
@@ -21,6 +22,7 @@ const DEFAULT_POLL_INTERVAL_MS = 30 * 60_000;
export class MobileAppUpdateService {
readonly state = signal<MobileUpdateState>(createInitialMobileUpdateState());
private readonly appI18n = inject(AppI18nService);
private readonly mobilePlatform = inject(MobilePlatformService);
private adapter: MobileAppUpdateAdapter = new WebMobileAppUpdateAdapter();
private adapterReady: Promise<MobileAppUpdateAdapter> | null = null;
@@ -41,7 +43,8 @@ export class MobileAppUpdateService {
const adapter = await this.ensureAdapter();
this.state.set(createInitialMobileUpdateState({
isSupported: adapter.isSupported
isSupported: adapter.isSupported,
translate: (key, params) => this.appI18n.instant(key, params)
}));
if (!adapter.isSupported) {
@@ -62,7 +65,7 @@ export class MobileAppUpdateService {
this.state.update((current) => ({
...current,
status: 'checking',
statusMessage: resolveMobileUpdateStatusMessage('checking')
statusMessage: resolveMobileUpdateStatusMessage('checking', null, null, (key, params) => this.appI18n.instant(key, params))
}));
try {
@@ -70,14 +73,18 @@ export class MobileAppUpdateService {
this.state.set(buildMobileUpdateState(snapshot, {
isSupported: true,
lastCheckedAt: Date.now()
lastCheckedAt: Date.now(),
translate: (key, params) => this.appI18n.instant(key, params)
}));
} catch (error) {
this.state.set({
...createInitialMobileUpdateState({ isSupported: true }),
...createInitialMobileUpdateState({
isSupported: true,
translate: (key, params) => this.appI18n.instant(key, params)
}),
lastCheckedAt: Date.now(),
status: 'error',
statusMessage: resolveMobileUpdateStatusMessage('error', error)
statusMessage: resolveMobileUpdateStatusMessage('error', error, null, (key, params) => this.appI18n.instant(key, params))
});
}
}
@@ -102,7 +109,7 @@ export class MobileAppUpdateService {
this.state.update((current) => ({
...current,
status: 'downloading',
statusMessage: resolveMobileUpdateStatusMessage('downloading')
statusMessage: resolveMobileUpdateStatusMessage('downloading', null, null, (key, params) => this.appI18n.instant(key, params))
}));
await adapter.performImmediateUpdate();
@@ -118,7 +125,7 @@ export class MobileAppUpdateService {
this.state.update((current) => ({
...current,
status: 'downloading',
statusMessage: resolveMobileUpdateStatusMessage('downloading')
statusMessage: resolveMobileUpdateStatusMessage('downloading', null, null, (key, params) => this.appI18n.instant(key, params))
}));
await adapter.startFlexibleUpdate();

View File

@@ -139,7 +139,7 @@ export class SignalingManager {
this.discardCurrentSocket();
this.connectionStatus$.next({
connected: false,
errorMessage: 'Timed out connecting to signaling server'
errorMessage: 'network.signaling.connectTimeout'
});
this.scheduleReconnect();
@@ -220,7 +220,7 @@ export class SignalingManager {
});
this.connectionStatus$.next({ connected: false,
errorMessage: 'Connection to signaling server failed' });
errorMessage: 'network.signaling.connectionFailed' });
observer.error(error);
};
@@ -241,7 +241,7 @@ export class SignalingManager {
this.stopHeartbeat();
this.connectionStatus$.next({ connected: false,
errorMessage: 'Disconnected from signaling server' });
errorMessage: 'network.signaling.disconnected' });
this.scheduleReconnect();
};
@@ -604,13 +604,13 @@ export class SignalingManager {
this.lastEndpointHealthOk = snapshot.ok;
if (!snapshot.ok) {
this.handleSocketTransportFailure('Signaling server health check failed');
this.handleSocketTransportFailure('network.signaling.healthCheckFailed');
return;
}
if (snapshot.serverInstanceId) {
if (previousServerInstanceId && snapshot.serverInstanceId !== previousServerInstanceId) {
this.handleSocketTransportFailure('Signaling server instance changed; refreshing websocket');
this.handleSocketTransportFailure('network.signaling.instanceChanged');
return;
}
@@ -618,7 +618,7 @@ export class SignalingManager {
}
if (wasHealthy === false) {
this.handleSocketTransportFailure('Signaling server recovered; refreshing websocket');
this.handleSocketTransportFailure('network.signaling.recovered');
}
} finally {
this.endpointHealthProbeInFlight = false;
@@ -638,7 +638,7 @@ export class SignalingManager {
return;
}
this.handleSocketTransportFailure('Signaling keepalive acknowledgement timed out');
this.handleSocketTransportFailure('network.signaling.keepaliveTimeout');
}
private sendKeepaliveIfDue(): void {
@@ -657,7 +657,7 @@ export class SignalingManager {
this.lastKeepaliveAckAt = this.lastKeepaliveSentAt;
}
} catch (error) {
this.handleSocketTransportFailure('Failed to send signaling keepalive', error);
this.handleSocketTransportFailure('network.signaling.keepaliveSendFailed', error);
}
}
@@ -712,7 +712,7 @@ export class SignalingManager {
url: details.url
});
this.handleSocketTransportFailure('Failed to send signaling payload', error);
this.handleSocketTransportFailure('network.signaling.payloadSendFailed', error);
throw error;
}

View File

@@ -171,6 +171,6 @@ export class WebRtcStateController {
this._isSignalingConnected.set(anyConnected);
this._hasConnectionError.set(!anyConnected);
this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'Disconnected from signaling server'));
this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'network.signaling.disconnected'));
}
}