diff --git a/apps/electron/build/icons/icon.icns b/apps/electron/build/icons/icon.icns index 7f3b591..a7f0a1e 100644 Binary files a/apps/electron/build/icons/icon.icns and b/apps/electron/build/icons/icon.icns differ diff --git a/apps/tauri/monochrome_cropped.png b/apps/tauri/monochrome_cropped.png new file mode 100644 index 0000000..424ebd1 Binary files /dev/null and b/apps/tauri/monochrome_cropped.png differ diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/tauri/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 72c8d06..cadb4d6 100644 --- a/apps/tauri/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/apps/tauri/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -56,6 +56,7 @@ = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -44,6 +48,7 @@ class MtpForegroundService : Service() { try { NativeMtpBridge.nativeAttach(applicationContext) NativeMtpBridge.nativeStart(config) + started = true NativeMtpBridge.log(2, "Started MTP foreground service") } catch (error: Throwable) { NativeMtpBridge.log(0, "Failed to start MTP foreground service", error) @@ -52,6 +57,13 @@ class MtpForegroundService : Service() { return START_STICKY } + override fun onTaskRemoved(rootIntent: Intent?) { + if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) { + startService(Intent(this, MtpForegroundService::class.java)) + } + super.onTaskRemoved(rootIntent) + } + override fun onDestroy() { if (!MtpSecureStore.isEnabled(this)) NativeMtpBridge.nativeStop() super.onDestroy() @@ -95,7 +107,7 @@ class MtpForegroundService : Service() { ) return NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_sync) - .setContentTitle("Tensamin background connection") + .setContentTitle("Tensamin") .setContentText(status) .setContentIntent(openIntent) .setOngoing(true) diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/NativeMtpBridge.kt b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/NativeMtpBridge.kt index 996daf0..ba9422d 100644 --- a/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/NativeMtpBridge.kt +++ b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/NativeMtpBridge.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.PowerManager @@ -13,7 +14,12 @@ import android.provider.Settings import android.util.Log import androidx.annotation.Keep import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.content.LocusIdCompat import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat @Keep object NativeMtpBridge { @@ -85,6 +91,7 @@ object NativeMtpBridge { senderId: Long, sender: String, body: String, + avatar: ByteArray, ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.getSystemService(NotificationManager::class.java).createNotificationChannel( @@ -107,11 +114,36 @@ object NativeMtpBridge { openIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + val avatarBitmap = avatar.takeIf { it.isNotEmpty() }?.let { + BitmapFactory.decodeByteArray(it, 0, it.size) + } + val avatarIcon = avatarBitmap?.let(IconCompat::createWithAdaptiveBitmap) + val person = Person.Builder() + .setName(sender) + .setKey(senderId.toString()) + .setIcon(avatarIcon) + .build() + val shortcutId = "chat-$senderId" + val shortcut = ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(sender) + .setLongLived(true) + .setPerson(person) + .setIntent(openIntent) + .apply { if (avatarIcon != null) setIcon(avatarIcon) } + .build() + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + + val style = NotificationCompat.MessagingStyle( + Person.Builder().setName("You").build(), + ).addMessage(body, System.currentTimeMillis(), person) val notification = NotificationCompat.Builder(context, MESSAGE_CHANNEL) - .setSmallIcon(android.R.drawable.sym_action_chat) + .setSmallIcon(R.drawable.ic_notification_small) .setContentTitle(sender) .setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setStyle(style) + .setShortcutId(shortcutId) + .setLocusId(LocusIdCompat(shortcutId)) + .setLargeIcon(avatarBitmap) .setCategory(Notification.CATEGORY_MESSAGE) .setAutoCancel(true) .setContentIntent(pendingIntent) @@ -121,4 +153,9 @@ object NativeMtpBridge { .notify(senderId.hashCode(), notification) } + fun cancelMessageNotification(context: Context, senderId: Long) { + context.getSystemService(NotificationManager::class.java) + .cancel(senderId.hashCode()) + } + } diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/res/drawable/ic_notification_small.png b/apps/tauri/src-tauri/gen/android/app/src/main/res/drawable/ic_notification_small.png new file mode 100644 index 0000000..bec055d Binary files /dev/null and b/apps/tauri/src-tauri/gen/android/app/src/main/res/drawable/ic_notification_small.png differ diff --git a/apps/tauri/src-tauri/icons/icon.icns b/apps/tauri/src-tauri/icons/icon.icns index 7f3b591..a7f0a1e 100644 Binary files a/apps/tauri/src-tauri/icons/icon.icns and b/apps/tauri/src-tauri/icons/icon.icns differ diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 5d49556..8131b57 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -17,7 +17,7 @@ pub fn run() { #[cfg(any(target_os = "ios", target_os = "android"))] let builder = builder.plugin(tauri_plugin_barcode_scanner::init()); - if let Err(error) = builder + let app = builder .invoke_handler(tauri::generate_handler![ mtp_backend::mtp_request, mtp_backend::mtp_status, @@ -26,6 +26,7 @@ pub fn run() { mtp_backend::mtp_load_keyring, mtp_backend::mtp_set_enabled, mtp_backend::mtp_set_ui_visible, + mtp_backend::mtp_post_message_notification, mtp_backend::mtp_is_ignoring_battery_optimizations, mtp_backend::mtp_request_battery_exemption, ]) @@ -45,9 +46,18 @@ pub fn run() { } Ok(()) }) - .run(tauri::generate_context!()) - { - eprintln!("error while running tauri application: {error}"); - panic!("error while running tauri application: {error}"); - } + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(|_app, event| { + #[cfg(target_os = "android")] + if let tauri::RunEvent::ExitRequested { + api, code: None, .. + } = event + { + if mtp_backend::manager().is_enabled() { + api.prevent_exit(); + } + } + }); } diff --git a/apps/tauri/src-tauri/src/mtp_backend.rs b/apps/tauri/src-tauri/src/mtp_backend.rs index 8cbfb8d..7c65b03 100644 --- a/apps/tauri/src-tauri/src/mtp_backend.rs +++ b/apps/tauri/src-tauri/src/mtp_backend.rs @@ -56,7 +56,7 @@ Zwkt6K2EOMmh1nvEzl83eMLYcod4GCl3b0J1Nn0CMBNYmEQJb4CEG5WoOe7aRn/L\n\ VKu6saHmHEynI7ysIPd8zQsK1HdmhlHKlw9Z5GpGvA==\n\ -----END CERTIFICATE-----\n"; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct MtpConfig { pub user_id: u64, @@ -135,6 +135,11 @@ impl MtpManager { pub fn configure_and_start(&'static self, config: MtpConfig) { let _guard = self.start_lock.lock().expect("start lock poisoned"); + if self.enabled.load(Ordering::SeqCst) + && self.config.read().expect("config lock poisoned").as_ref() == Some(&config) + { + return; + } *self.config.write().expect("config lock poisoned") = Some(config); self.enabled.store(true, Ordering::SeqCst); let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; @@ -182,6 +187,10 @@ impl MtpManager { self.ui_visible.store(visible, Ordering::SeqCst); } + pub fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::SeqCst) + } + pub fn snapshot(&self) -> MtpSnapshot { self.snapshot .read() @@ -446,6 +455,19 @@ async fn handle_push(generation: u64, connection: Arc, frame: Com message, }); } + if frame.is_type(CommunicationType::MessageState) + && frame.get_str(DataType::MessageState) == Some("read") + { + if let Some(partner_id) = frame + .get_data(DataType::ChatPartnerId) + .as_number() + .and_then(|value| u64::try_from(value).ok()) + { + if let Err(error) = android_cancel_notification(partner_id) { + eprintln!("failed to clear read message notification: {error}"); + } + } + } if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) { if let Err(error) = notify_message(connection, &frame).await { eprintln!("failed to create background message notification: {error}"); @@ -533,7 +555,15 @@ async fn notify_message( .or_else(|| user.get_str(DataType::Username)) .map(str::to_owned) .unwrap_or_else(|| format!("User {sender_id}")); - android_notify(sender_id, &sender, &String::from_utf8_lossy(&plaintext)); + let avatar = user + .get_str(DataType::Avatar) + .and_then(|avatar| decode_browser_base64(avatar).ok()); + android_notify( + sender_id, + &sender, + &String::from_utf8_lossy(&plaintext), + avatar.as_deref(), + )?; Ok(()) } @@ -834,6 +864,26 @@ pub fn mtp_set_ui_visible(visible: bool) { manager().set_ui_visible(visible); } +#[tauri::command] +pub fn mtp_post_message_notification( + sender_id: u64, + sender: String, + body: String, + avatar: Option, +) -> Result { + #[cfg(target_os = "android")] + { + let avatar = avatar.as_deref().map(decode_browser_base64).transpose()?; + android_notify(sender_id, &sender, &body, avatar.as_deref())?; + Ok(true) + } + #[cfg(not(target_os = "android"))] + { + let _ = (sender_id, sender, body, avatar); + Ok(false) + } +} + #[cfg(not(target_os = "android"))] fn android_store_config(_: &str) -> Result<(), String> { Ok(()) @@ -853,7 +903,13 @@ fn android_set_enabled(_: bool) -> Result<(), String> { #[cfg(not(target_os = "android"))] fn android_status(_: &str) {} #[cfg(not(target_os = "android"))] -fn android_notify(_: u64, _: &str, _: &str) {} +fn android_notify(_: u64, _: &str, _: &str, _: Option<&[u8]>) -> Result<(), String> { + Ok(()) +} +#[cfg(not(target_os = "android"))] +fn android_cancel_notification(_: u64) -> Result<(), String> { + Ok(()) +} #[cfg(not(target_os = "android"))] fn android_is_ignoring_battery_optimizations() -> Result { Ok(true) @@ -974,24 +1030,49 @@ mod android { }); } - pub fn notify(sender_id: u64, sender: &str, body: &str) { - let _ = with_env(|env, host| { + pub fn notify( + sender_id: u64, + sender: &str, + body: &str, + avatar: Option<&[u8]>, + ) -> Result<(), String> { + with_env(|env, host| { let sender = env.new_string(sender).map_err(|e| e.to_string())?; let body = env.new_string(body).map_err(|e| e.to_string())?; + let avatar = env + .byte_array_from_slice(avatar.unwrap_or_default()) + .map_err(|e| e.to_string())?; env.call_method( host.bridge.as_obj(), "postMessageNotification", - "(Landroid/content/Context;JLjava/lang/String;Ljava/lang/String;)V", + "(Landroid/content/Context;JLjava/lang/String;Ljava/lang/String;[B)V", &[ JValue::Object(host.context.as_obj()), JValue::Long(sender_id as i64), JValue::Object(&sender), JValue::Object(&body), + JValue::Object(&avatar), ], ) .map_err(|e| e.to_string())?; Ok(()) - }); + }) + } + + pub fn cancel_notification(sender_id: u64) -> Result<(), String> { + with_env(|env, host| { + env.call_method( + host.bridge.as_obj(), + "cancelMessageNotification", + "(Landroid/content/Context;J)V", + &[ + JValue::Object(host.context.as_obj()), + JValue::Long(sender_id as i64), + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) + }) } pub fn is_ignoring_battery_optimizations() -> Result { @@ -1084,7 +1165,7 @@ mod android { #[cfg(target_os = "android")] use android::{ - has_config as android_has_config, + cancel_notification as android_cancel_notification, has_config as android_has_config, is_ignoring_battery_optimizations as android_is_ignoring_battery_optimizations, notify as android_notify, request_battery_exemption as android_request_battery_exemption, set_enabled as android_set_enabled, status as android_status, diff --git a/apps/tauri/src/deeplinkHandler.tsx b/apps/tauri/src/deeplinkHandler.tsx index 2094b73..dcbb5c7 100644 --- a/apps/tauri/src/deeplinkHandler.tsx +++ b/apps/tauri/src/deeplinkHandler.tsx @@ -9,11 +9,11 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { isTauri } from "@tauri-apps/api/core"; import { useIsMobile } from "@methanium/ui"; -type DeeplinkContextValue = { - deeplinks: readonly string[]; -}; -export const deeplinkContext = createContext( + +export const deeplinkContext = createContext<{ + deeplinks: readonly string[]; +} | undefined>( undefined, ); diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 6fbeae6..60d5ac9 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -27,7 +27,7 @@ import { useCall, useInitializeCall } from "@tensamin/call/store"; import { useIsSpeaking } from "@tensamin/call/speakingState"; import { Provider as MTPProvider } from "@tensamin/mtp"; import UserProvider from "@tensamin/user/context"; -import DeeplinkContext from "@tensamin/tauri/deeplinkHandler"; +import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; import NotificationsProvider from "@tensamin/notifications/context"; import TAuthWrapper from "@tensamin/tauth/context"; @@ -288,6 +288,7 @@ function AppShell() { + @@ -309,6 +310,36 @@ function AppShell() { ); } +function DeeplinkNavigator() { + const { deeplinks } = useDeeplinks(); + const navigate = useNavigate(); + const handledCount = useRef(0); + + useEffect(() => { + const links = deeplinks.slice(handledCount.current); + handledCount.current = deeplinks.length; + + for (const link of links) { + try { + const url = new URL(link); + const id = Number(url.searchParams.get("id")); + if ( + url.protocol === "tensamin:" && + url.hostname === "chat" && + Number.isSafeInteger(id) && + id > 0 + ) { + void navigate({ to: "/chat", search: { id } }); + } + } catch { + // Ignore malformed URLs delivered by the platform. + } + } + }, [deeplinks, navigate]); + + return null; +} + function createCallTrayIcon(color: string, speaking: boolean) { const canvas = document.createElement("canvas"); canvas.width = 32; diff --git a/eslint.config.ts b/eslint.config.ts index df086d6..f9bc102 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -6,6 +6,7 @@ import * as tsParser from "@typescript-eslint/parser"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { + inlineSingleUseDeclarations, noReactNamespaceImport, noWindowLocationReload, } from "./utils/eslint-rules/index.js"; @@ -34,6 +35,7 @@ export default [ "react-hooks": reactHooks, tensamin: { rules: { + "inline-single-use-declarations": inlineSingleUseDeclarations, "no-react-namespace-import": noReactNamespaceImport, "no-window-location-reload": noWindowLocationReload, }, @@ -42,6 +44,7 @@ export default [ rules: { ...reactHooks.configs.recommended.rules, "react-hooks/set-state-in-effect": "off", + "tensamin/inline-single-use-declarations": "error", "tensamin/no-react-namespace-import": "error", "tensamin/no-window-location-reload": "error", }, diff --git a/packages/call/src/mediaShare/controller.ts b/packages/call/src/mediaShare/controller.ts index a2fde87..f123834 100644 --- a/packages/call/src/mediaShare/controller.ts +++ b/packages/call/src/mediaShare/controller.ts @@ -17,22 +17,6 @@ type MediaShareStoreState = { cameraSession: LocalMediaShareSession | null; }; -type MediaShareStoreSetState = ( - updater: - | Partial - | ((state: MediaShareStoreState) => Partial), -) => void; - -type MediaShareControllerOptions = { - room: Room; - getState: () => MediaShareStoreState; - setState: MediaShareStoreSetState; - getLocalParticipantId: () => number | null; - startWatching: (participantId: number) => void; - stopWatching: (participantId: number) => void; - syncParticipantState: () => void; -}; - export function createMediaShareController({ room, getState, @@ -41,7 +25,19 @@ export function createMediaShareController({ startWatching, stopWatching, syncParticipantState, -}: MediaShareControllerOptions) { +}: { + room: Room; + getState: () => MediaShareStoreState; + setState: ( + updater: + | Partial + | ((state: MediaShareStoreState) => Partial), + ) => void; + getLocalParticipantId: () => number | null; + startWatching: (participantId: number) => void; + stopWatching: (participantId: number) => void; + syncParticipantState: () => void; +}) { function getSession(kind: MediaShareKind) { return kind === "screen" ? getState().screenShareSession diff --git a/packages/call/src/mediaShare/tauri.ts b/packages/call/src/mediaShare/tauri.ts index e88034e..7263599 100644 --- a/packages/call/src/mediaShare/tauri.ts +++ b/packages/call/src/mediaShare/tauri.ts @@ -8,32 +8,16 @@ import type { MediaShareSource, } from "./types"; -type MobileMediaApi = { - startScreenShare: (includeAudio: boolean) => void; - stopScreenShare: () => void; - requestCameraPermission: () => void; -}; - declare global { interface Window { - tensaminMobileMedia?: MobileMediaApi; + tensaminMobileMedia?: { + startScreenShare: (includeAudio: boolean) => void; + stopScreenShare: () => void; + requestCameraPermission: () => void; + }; } } -type FrameDetail = { - data: string; - mimeType: string; - width: number; - height: number; -}; - -type AudioDetail = { - data: string; - sampleRate: number; - channelCount: number; - encoding: "pcm16le"; -}; - function eventDetail(event: Event): T { return (event as CustomEvent).detail; } @@ -164,7 +148,12 @@ async function startMobileScreen( }; const onFrame = (event: Event) => { - const detail = eventDetail(event); + const detail = eventDetail<{ + data: string; + mimeType: string; + width: number; + height: number; + }>(event); const image = new Image(); image.onload = () => { if (canvas.width !== detail.width || canvas.height !== detail.height) { @@ -179,7 +168,14 @@ async function startMobileScreen( }; const onAudio = (event: Event) => { - const bytes = decodeBase64(eventDetail(event).data); + const bytes = decodeBase64( + eventDetail<{ + data: string; + sampleRate: number; + channelCount: number; + encoding: "pcm16le"; + }>(event).data, + ); const samples = new Int16Array( bytes.buffer, bytes.byteOffset, diff --git a/packages/call/src/speakingIndicator.ts b/packages/call/src/speakingIndicator.ts index a887516..94ff030 100644 --- a/packages/call/src/speakingIndicator.ts +++ b/packages/call/src/speakingIndicator.ts @@ -10,18 +10,19 @@ const SPEAKING_HANGTIME_MS = 500; const ANALYSIS_INTERVAL_MS = 30; const FFT_SIZE = 256; -type AnalyserEntry = { - source: MediaStreamAudioSourceNode; - analyser: AnalyserNode; - track: MediaStreamTrack; - originalTrack?: MediaStreamTrack; - lastSpeakingTime: number; - isSpeaking: boolean; -}; - class SpeakingDetector { private audioContext: AudioContext | null = null; - private entries = new Map(); + private entries = new Map< + number, + { + source: MediaStreamAudioSourceNode; + analyser: AnalyserNode; + track: MediaStreamTrack; + originalTrack?: MediaStreamTrack; + lastSpeakingTime: number; + isSpeaking: boolean; + } + >(); private intervalId: ReturnType | null = null; private deaf = false; private gateThresholdStart = -50; diff --git a/packages/call/src/speakingState.ts b/packages/call/src/speakingState.ts index b689bf5..c4f5198 100644 --- a/packages/call/src/speakingState.ts +++ b/packages/call/src/speakingState.ts @@ -1,12 +1,10 @@ import { create } from "zustand"; -type SpeakingState = { +const useSpeakingState = create<{ speakingParticipantIds: Set; lastSpeakingParticipantId: number | null; micGated: boolean; -}; - -const useSpeakingState = create(() => ({ +}>(() => ({ speakingParticipantIds: new Set(), lastSpeakingParticipantId: null, micGated: false, diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 5def2b6..687919b 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -51,7 +51,6 @@ setLogExtension( getLogger("tensamin"), ); -type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; type CallView = "preview" | "focused" | "grid"; type ProtocolCallSecret = NonNullable< z.infer["CallSecret"] @@ -63,18 +62,9 @@ type WrappedCallSecret = { kemCiphertext: Uint8Array; wrappingScheme: string; }; -type IncomingCallInvite = { - callId: string; - callSecret: WrappedCallSecret; - senderId: number; -}; type CurrentCallData = (z.infer & { exists: boolean }) | null; -type NavigateFn = (options: { - to: string; - search?: Record; -}) => Promise; type SendFn = ( type: string, data: Record, @@ -84,44 +74,15 @@ type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>; type RemoteVideoTrackSelector = Track.Kind | Track.Source; type Runtime = { - navigate: NavigateFn; + navigate: (options: { + to: string; + search?: Record; + }) => Promise; send: SendFn; load: LoadFn; getUser: GetUserFn; }; -type CallStore = { - state: CallState; - view: CallView; - invitedUserId: number | null; - callId: string | null; - incomingCallInvite: IncomingCallInvite | null; - callSecret: string | null; - livekitToken: string | null; - currentCallData: CurrentCallData; - deaf: boolean; - micEnabled: boolean; - cameraEnabled: boolean; - screenShareEnabled: boolean; - screenShareSession: LocalMediaShareSession | null; - cameraSession: LocalMediaShareSession | null; - disabledCameraParticipantIds: number[]; - focusedParticipantId: number | null; - focusedParticipantType: "user" | "stream" | null; - usersInFocusedViewHidden: boolean; - watchedStreamParticipantIds: number[]; - pendingWatchedParticipantIds: number[]; - activeScreenShareParticipantIds: number[]; - isEncrypted: boolean; - ownCallSecretInvitePending: boolean; - callIsFullscreen: boolean; - callIsPopout: boolean; - layoutVersion: number; - screenRef: React.RefObject | null; - runtime: Runtime | null; - lastFocusedParticipantId: number | null; -}; - let _keyProvider: ExternalE2EEKeyProvider | null = null; let _e2eeWorker: Worker | null = null; let _room: Room | null = null; @@ -1195,7 +1156,41 @@ async function ensureNoiseFilter( } } -export const useCall = create(() => ({ +export const useCall = create<{ + state: "closed" | "closing" | "connecting" | "open" | "encrypting"; + view: CallView; + invitedUserId: number | null; + callId: string | null; + incomingCallInvite: { + callId: string; + callSecret: WrappedCallSecret; + senderId: number; + } | null; + callSecret: string | null; + livekitToken: string | null; + currentCallData: CurrentCallData; + deaf: boolean; + micEnabled: boolean; + cameraEnabled: boolean; + screenShareEnabled: boolean; + screenShareSession: LocalMediaShareSession | null; + cameraSession: LocalMediaShareSession | null; + disabledCameraParticipantIds: number[]; + focusedParticipantId: number | null; + focusedParticipantType: "user" | "stream" | null; + usersInFocusedViewHidden: boolean; + watchedStreamParticipantIds: number[]; + pendingWatchedParticipantIds: number[]; + activeScreenShareParticipantIds: number[]; + isEncrypted: boolean; + ownCallSecretInvitePending: boolean; + callIsFullscreen: boolean; + callIsPopout: boolean; + layoutVersion: number; + screenRef: React.RefObject | null; + runtime: Runtime | null; + lastFocusedParticipantId: number | null; +}>(() => ({ state: "closed", view: "preview", invitedUserId: null, diff --git a/packages/chat/src/components/gifPicker.tsx b/packages/chat/src/components/gifPicker.tsx index fbec74b..e44742b 100644 --- a/packages/chat/src/components/gifPicker.tsx +++ b/packages/chat/src/components/gifPicker.tsx @@ -34,35 +34,25 @@ function getColumnCount(width: number, itemCount: number) { type KlipyKind = "gif" | "meme"; -type KlipyMediaFile = { - url?: string; - width?: number; - height?: number; -}; - -type KlipyMediaFormats = Record; - type KlipyItem = { id: number | string; title?: string; - file?: Record; + file?: Record< + string, + | Record< + string, + | { + url?: string; + width?: number; + height?: number; + } + | undefined + > + | undefined + >; blur_preview?: string; }; -type KlipyPage = { - items: KlipyItem[]; - currentPage: number; - hasNext: boolean; -}; - -type KlipyResponse = { - data?: { - data?: KlipyItem[]; - current_page?: number; - has_next?: boolean; - }; -}; - type PickerMedia = { key: React.Key; url: string; @@ -105,7 +95,11 @@ async function fetchKlipyPage({ kind: KlipyKind; page: number; search: string; -}): Promise { +}): Promise<{ + items: KlipyItem[]; + currentPage: number; + hasNext: boolean; +}> { const params = new URLSearchParams({ page: String(page), per_page: String(pageSize), @@ -128,7 +122,13 @@ async function fetchKlipyPage({ throw new Error(`Klipy request failed with status ${response.status}`); } - const body = (await response.json()) as KlipyResponse; + const body = (await response.json()) as { + data?: { + data?: KlipyItem[]; + current_page?: number; + has_next?: boolean; + }; + }; const data = body.data; return { diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 5d6088a..e54235d 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -66,6 +66,13 @@ export default function InputComponent({ return () => cancelAnimationFrame(frame); }, [userId]); + useEffect(() => { + if (replyTo === undefined) return; + + const frame = requestAnimationFrame(() => composerRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [replyTo]); + useEffect(() => { const focusComposerOnType = (event: KeyboardEvent) => { const composer = composerRef.current; diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 78c1bb2..5b6e796 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -145,8 +145,6 @@ type SendMessageGet = ( data: { SendTime: number }, ) => Promise<{ data: RawMessage }>; -type GetChatSecret = (userId: number) => Promise; - type StoredDraftState = ChatDraft & { accountId: number; userId: number; @@ -204,7 +202,7 @@ export async function fetchReplyMessage({ ownId: number; chatUserId: number; send: SendMessageGet; - getChatSecret: GetChatSecret; + getChatSecret: (userId: number) => Promise; }) { const message = await getMessage({ sendTime: replyTo, diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 8b3778e..0587d54 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -21,12 +21,6 @@ import { } from "./values"; import Wrapper from "@tensamin/user/wrapper"; -type MessageChunk = { - key: string; - messages: Array; - startIndex: number; -}; - function shouldFetchPreviousPage({ entry, hasNextPage, @@ -108,7 +102,11 @@ function buildMessageChunks( keyPrefix: string, startOffset = 0, ) { - const chunks: MessageChunk[] = []; + const chunks: { + key: string; + messages: Array; + startIndex: number; + }[] = []; for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) { const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW); @@ -282,7 +280,7 @@ export default function Screen() { getItemKey, estimateSize, overscan: 2, - paddingStart: composerHeight + 8, + paddingStart: composerHeight + 20, }); const totalSize = virtualizer.getTotalSize(); const contentHeight = Math.max(totalSize, viewportHeight); diff --git a/packages/markdown/src/emojiData.ts b/packages/markdown/src/emojiData.ts index f1ed5ab..158b5b0 100644 --- a/packages/markdown/src/emojiData.ts +++ b/packages/markdown/src/emojiData.ts @@ -1,7 +1,5 @@ import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json"; -type ShortcodeValue = string | string[]; - export type EmojiDefinition = { aliases: readonly string[]; hexcode: string; @@ -17,7 +15,7 @@ function normalizeName(value: string) { } export const emojis: readonly EmojiDefinition[] = Object.entries( - shortcodeData as Record, + shortcodeData as Record, ).map(([hexcode, value]) => { const aliases = Array.isArray(value) ? value : [value]; const name = aliases[0]; diff --git a/packages/markdown/src/input.tsx b/packages/markdown/src/input.tsx index 58ebe9f..e16c84d 100644 --- a/packages/markdown/src/input.tsx +++ b/packages/markdown/src/input.tsx @@ -76,10 +76,6 @@ export type InputProps = { onControllerChange?: (controller: InputController | null) => void; }; -type InputStyle = CSSProperties & { - "--tm-md-content-padding"?: string; -}; - function toCssLength(value: CSSProperties["padding"]): string | undefined { if (value === undefined) { return undefined; @@ -99,11 +95,6 @@ function toCssPadding( return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`; } -type TokenRange = { - from: number; - to: number; -}; - const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" }); const strongDecoration = Decoration.mark({ class: "tm-md-strong" }); const emDecoration = Decoration.mark({ class: "tm-md-em" }); @@ -443,7 +434,9 @@ export default function Input(props: InputProps) { props.paddingX, Boolean(props.styled), ), - } as InputStyle + } as CSSProperties & { + "--tm-md-content-padding"?: string; + } } /> ); @@ -834,7 +827,10 @@ function buildDecorations(view: EditorView): DecorationSet { function addHiddenToken( builder: Range[], selections: ReadonlyArray<{ from: number; to: number }>, - token: TokenRange, + token: { + from: number; + to: number; + }, ): void { if (token.from >= token.to) return; diff --git a/packages/markdown/src/markdown.tsx b/packages/markdown/src/markdown.tsx index 95b4a87..bc64692 100644 --- a/packages/markdown/src/markdown.tsx +++ b/packages/markdown/src/markdown.tsx @@ -1,4 +1,11 @@ -import { Fragment, type ReactElement, type ReactNode } from "react"; +import { + Fragment, + useEffect, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; import Emoji from "./emoji"; import { findEmojiShortcodes } from "./emojiData"; @@ -23,43 +30,11 @@ type InlineTokenRange = { to: number; }; -type ParagraphBlock = { - type: "paragraph"; - text: string; -}; - -type HeadingBlock = { - type: "heading"; - level: number; - text: string; -}; - -type HrBlock = { - type: "hr"; -}; - -type BlockQuoteBlock = { - type: "blockquote"; - text: string; -}; - -type CodeBlock = { - type: "code"; - language: string; - code: string; -}; - type ListItem = { text: string; checked: boolean | null; }; -type ListBlock = { - type: "list"; - ordered: boolean; - items: ListItem[]; -}; - type TableBlock = { type: "table"; headers: string[]; @@ -67,17 +42,140 @@ type TableBlock = { }; type MarkdownBlock = - | ParagraphBlock - | HeadingBlock - | HrBlock - | BlockQuoteBlock - | CodeBlock - | ListBlock + | { + type: "paragraph"; + text: string; + } + | { + type: "heading"; + level: number; + text: string; + } + | { + type: "hr"; + } + | { + type: "blockquote"; + text: string; + } + | { + type: "code"; + language: string; + code: string; + } + | { + type: "list"; + ordered: boolean; + items: ListItem[]; + } | TableBlock; const INLINE_TOKEN_REGEX = /!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(? + + Copied + + ); +} + +function CopyableCode({ + block = false, + language, + value, +}: { + block?: boolean; + language?: string; + value: string; +}) { + const [copied, setCopied] = useState(false); + const copiedTimer = useRef | undefined>( + undefined, + ); + + useEffect( + () => () => { + clearTimeout(copiedTimer.current); + }, + [], + ); + + async function copy() { + await navigator.clipboard.writeText(value); + setCopied(true); + clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(false), 1200); + } + + const code = ( + void copy()} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + void copy(); + }} + > + {value} + + ); + + if (block) { + return ( +
+
{code}
+ +
+ ); + } + + return ( + <> + {code} + + + ); +} + /** * Executes parseInlineNodes. * @param input Parameter input. @@ -429,11 +527,7 @@ function renderInline(nodes: InlineNode[]): ReactNode[] { } if (node.type === "code") { - return ( - - {node.value} - - ); + return ; } if (node.type === "link") { @@ -523,11 +617,12 @@ export function renderBlocks(blocks: MarkdownBlock[]): ReactElement { if (block.type === "code") { return ( -
-              
-                {block.code}
-              
-            
+ ); } @@ -666,7 +761,7 @@ function readTable( } const markdownStyles = ` -.tm-md-root { color: hsl(var(--foreground)); line-height: 1.65; font-size: 1rem; } +.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; } .tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; } .tm-md-h1 { font-size: 1.65rem; } .tm-md-h2 { font-size: 1.45rem; } @@ -676,13 +771,15 @@ const markdownStyles = ` .tm-md-h6 { font-size: 0.95rem; opacity: 0.9; } .tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; } .tm-md-blockquote p { margin: 0.2rem 0; } -.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: hsl(var(--muted)); overflow-x: auto; } -.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } -.tm-md-code { padding: 0.08rem 0.32rem; border-radius: 0.28rem; background: hsl(var(--muted)); } +.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: var(--muted); overflow-x: auto; } +.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; } +.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); } +.tm-md-codeblock { display: block; } +.tm-md-code:focus-visible, .tm-md-codeblock:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .tm-md-strong { font-weight: 700; } .tm-md-em { font-style: italic; } .tm-md-del { text-decoration: line-through; } -.tm-md-link { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 0.14rem; } +.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; } .tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; } .tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; } .tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; } @@ -691,7 +788,7 @@ const markdownStyles = ` .tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; } .tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; } .tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; } -.tm-md-table th { background: hsl(var(--muted)); font-weight: 600; } +.tm-md-table th { background: var(--muted); font-weight: 600; } .tm-md-hr { margin: 0.55rem 0; } .cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); } @@ -699,10 +796,10 @@ const markdownStyles = ` .cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; } .cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); } .cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; } -.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); } +.cm-editor.tm-md-editor .cm-line { padding: 0; color: var(--foreground); } .cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; } .cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; } -.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; } +.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; } .cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; } .cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } .cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; } @@ -726,10 +823,15 @@ export function ensureMarkdownStyles(): void { if (typeof document === "undefined") return; const styleId = "tensamin-markdown-styles"; - if (document.getElementById(styleId)) return; + let style = document.getElementById(styleId) as HTMLStyleElement | null; - const style = document.createElement("style"); - style.id = styleId; - style.textContent = markdownStyles; - document.head.appendChild(style); + if (!style) { + style = document.createElement("style"); + style.id = styleId; + document.head.appendChild(style); + } + + if (style.textContent !== markdownStyles) { + style.textContent = markdownStyles; + } } diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index 2c9f1e5..69010a9 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -631,16 +631,6 @@ type NativeSnapshot = { error?: string; }; -type NativeEvent = - | { kind: "state"; snapshot: NativeSnapshot } - | { kind: "message"; generation: number; message: unknown } - | { - kind: "log"; - level: number; - message: string; - details?: unknown; - }; - function TauriProvider(props: { children: ReactNode; blockConnection?: boolean; @@ -713,7 +703,16 @@ function TauriProvider(props: { let disposed = false; let unlisten: UnlistenFn | undefined; void (async () => { - unlisten = await listen("mtp://event", ({ payload }) => { + unlisten = await listen< + | { kind: "state"; snapshot: NativeSnapshot } + | { kind: "message"; generation: number; message: unknown } + | { + kind: "log"; + level: number; + message: string; + details?: unknown; + } + >("mtp://event", ({ payload }) => { if (disposed) return; if (payload.kind === "state") { applySnapshot(payload.snapshot); diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx index 453d4e7..a63b906 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -5,7 +5,7 @@ import { useMTP } from "@tensamin/mtp"; import { createContext, useEffect, useContext } from "react"; import { toast as sonnerToast } from "sonner"; import { Avatar, AvatarFallback, AvatarImage } from "@methanium/ui"; -import { isTauri } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { isPermissionGranted as isTauriNotificationPermissionGranted, requestPermission as requestTauriNotificationPermission, @@ -108,7 +108,30 @@ export default function Provider(props: { children: React.ReactNode }) { (await requestTauriNotificationPermission()) === "granted"; if (permissionGranted) { - sendTauriNotification({ title: user.Display, body: content }); + let handledNatively = false; + try { + handledNatively = await invoke( + "mtp_post_message_notification", + { + senderId: user.UserId, + sender: user.Display, + body: content, + avatar: user.Avatar, + }, + ); + } catch (error) { + log( + 1, + "notifications", + "red", + "Failed to create native message notification", + error, + ); + } + + if (!handledNatively) { + sendTauriNotification({ title: user.Display, body: content }); + } } } else { const hasPermissions = await requestNotificationPermission(); diff --git a/packages/onboarding/src/index.tsx b/packages/onboarding/src/index.tsx index da7971e..5efa9c9 100644 --- a/packages/onboarding/src/index.tsx +++ b/packages/onboarding/src/index.tsx @@ -21,10 +21,10 @@ export { type OnboardingStepControls, } from "@methanium/ui"; -type LegalDocs = z.infer; + interface GateState { - docs: LegalDocs; + docs: z.infer; acceptedPP: boolean; acceptedTOS: boolean; includeLegal: boolean; diff --git a/packages/onboarding/src/pages/legal.tsx b/packages/onboarding/src/pages/legal.tsx index 495d143..86c8133 100644 --- a/packages/onboarding/src/pages/legal.tsx +++ b/packages/onboarding/src/pages/legal.tsx @@ -5,7 +5,7 @@ import type { z } from "zod"; import { useOnboardingStep } from "@methanium/ui"; -type LegalDocs = z.infer; + export default function LegalPage({ docs, @@ -13,7 +13,7 @@ export default function LegalPage({ initiallyAcceptedTOS, onAccept, }: { - docs: LegalDocs; + docs: z.infer; initiallyAcceptedPP: boolean; initiallyAcceptedTOS: boolean; onAccept: () => Promise; diff --git a/packages/settings/src/components.tsx b/packages/settings/src/components.tsx index 315ba78..f2ca317 100644 --- a/packages/settings/src/components.tsx +++ b/packages/settings/src/components.tsx @@ -5,9 +5,7 @@ import { storageDefaults, type Storage } from "@tensamin/shared/data"; import { settingsStorageDefaults } from "@tensamin/shared/settings"; import { useStorage } from "@tensamin/storage/context"; -type BooleanStorageKey = { - [K in keyof Storage]: Storage[K] extends boolean ? K : never; -}[keyof Storage]; + type ListStorageKey = { [K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never; @@ -21,7 +19,9 @@ export function Switch({ id, }: { label: React.ReactNode; - id: keyof typeof settingsStorageDefaults & BooleanStorageKey; + id: keyof typeof settingsStorageDefaults & ({ + [K in keyof Storage]: Storage[K] extends boolean ? K : never; +}[keyof Storage]); }) { const { save, load } = useStorage(); const [value, setValue] = useState(settingsStorageDefaults[id]); diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 4dc5cfd..8480258 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -143,25 +143,7 @@ export type Contacts = z.infer; export type Communities = z.infer; export type Calls = z.infer; -type Base16Palette = Record< - | "base00" - | "base01" - | "base02" - | "base03" - | "base04" - | "base05" - | "base06" - | "base07" - | "base08" - | "base09" - | "base0A" - | "base0B" - | "base0C" - | "base0D" - | "base0E" - | "base0F", - string ->; + // MTP const user = z.object({ @@ -472,7 +454,25 @@ export interface Storage extends SettingsStorageDefaults { call_mute_range_start: number; call_mute_range_end: number; theme_color: string; - theme_palette: Base16Palette | null; + theme_palette: Record< + | "base00" + | "base01" + | "base02" + | "base03" + | "base04" + | "base05" + | "base06" + | "base07" + | "base08" + | "base09" + | "base0A" + | "base0B" + | "base0C" + | "base0D" + | "base0E" + | "base0F", + string +> | null; theme_primary_color: string; theme_polarity: "dark" | "light" | "system"; theme_tint: "soft" | "hard" | "extreme"; diff --git a/packages/shared/src/desktopMedia.tsx b/packages/shared/src/desktopMedia.tsx index 75550cd..6799966 100644 --- a/packages/shared/src/desktopMedia.tsx +++ b/packages/shared/src/desktopMedia.tsx @@ -22,7 +22,11 @@ export type DesktopScreenShareCapabilities = { hasReliableSystemAudio: boolean; }; -type ElectronDesktopApi = { + + +declare global { + interface Window { + tensaminDesktop?: { media?: { getScreenShareCapabilities?: () => Promise; listScreenShareSources?: () => Promise; @@ -53,10 +57,6 @@ type ElectronDesktopApi = { clear?: () => Promise; }; }; - -declare global { - interface Window { - tensaminDesktop?: ElectronDesktopApi; } } diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts index 0c3116b..6fd4d2a 100644 --- a/packages/shared/src/settings.ts +++ b/packages/shared/src/settings.ts @@ -1,14 +1,14 @@ type StringKeyOf = Extract; -type SettingDefinition = { - display: string; - type: string; - default?: unknown; -}; + export type SettingsSchema = Record< string, - Record> + Record> >; const settings = { diff --git a/utils/eslint-rules/index.ts b/utils/eslint-rules/index.ts index 8dee264..ea4eea7 100644 --- a/utils/eslint-rules/index.ts +++ b/utils/eslint-rules/index.ts @@ -72,3 +72,211 @@ export const noWindowLocationReload: Rule.RuleModule = { }; }, }; + +interface AstNode { + type: string; + parent: AstNode | null; + range: [number, number]; +} + +interface TypeAliasDeclaration extends AstNode { + type: "TSTypeAliasDeclaration"; + typeAnnotation: AstNode; + typeParameters?: unknown; +} + +interface FunctionDeclaration extends AstNode { + type: "FunctionDeclaration"; + async: boolean; + generator: boolean; + params: AstNode[]; + body: AstNode & { body: AstNode[] }; +} + +interface ReturnStatement extends AstNode { + type: "ReturnStatement"; + argument: AstNode | null; +} + +function isExported(node: AstNode): boolean { + return ( + node.parent?.type === "ExportNamedDeclaration" || + node.parent?.type === "ExportDefaultDeclaration" + ); +} + +function containsContextSensitiveNode(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + + const node = value as { type?: string; [key: string]: unknown }; + if ( + node.type === "ThisExpression" || + node.type === "Super" || + node.type === "MetaProperty" + ) { + return true; + } + + return Object.entries(node).some( + ([key, child]) => + key !== "parent" && + key !== "loc" && + key !== "range" && + containsContextSensitiveNode(child), + ); +} + +const unambiguousInlineTypes = new Set([ + "TSAnyKeyword", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSStringKeyword", + "TSSymbolKeyword", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +export const inlineSingleUseDeclarations: Rule.RuleModule = { + meta: { + type: "suggestion", + docs: { + description: "Inline local types and functions that are used only once", + }, + fixable: "code", + messages: { + function: "Inline this function at its only call site.", + type: "Inline this type at its only use site.", + }, + schema: [], + }, + create(context) { + const sourceCode = context.sourceCode; + + return { + TSTypeAliasDeclaration(untypedNode: Rule.Node) { + const node = untypedNode as unknown as TypeAliasDeclaration; + if (node.typeParameters || isExported(node)) return; + + const eslintNode = node as unknown as Rule.Node; + const [variable] = sourceCode.getDeclaredVariables(eslintNode); + if (!variable || variable.references.length !== 1) return; + + const reference = variable.references[0] + .identifier as unknown as AstNode; + if ( + reference.range[0] >= node.range[0] && + reference.range[1] <= node.range[1] + ) { + return; + } + + const referenceParent = reference.parent; + if ( + !referenceParent || + referenceParent.type !== "TSTypeReference" || + referenceParent.parent?.type === "TSClassImplements" || + referenceParent.parent?.type === "TSInterfaceHeritage" + ) { + return; + } + + context.report({ + node: eslintNode, + messageId: "type", + fix(fixer) { + const annotation = sourceCode.getText( + node.typeAnnotation as unknown as Rule.Node, + ); + const replacement = unambiguousInlineTypes.has( + node.typeAnnotation.type, + ) + ? annotation + : `(${annotation})`; + + return [ + fixer.replaceText( + referenceParent as unknown as Rule.Node, + replacement, + ), + fixer.remove(eslintNode), + ]; + }, + }); + }, + + FunctionDeclaration(untypedNode: Rule.Node) { + const node = untypedNode as unknown as FunctionDeclaration; + if ( + node.async || + node.generator || + node.params.length !== 0 || + node.body.body.length !== 1 || + isExported(node) + ) { + return; + } + + const statement = node.body.body[0] as ReturnStatement; + if (statement.type !== "ReturnStatement" || !statement.argument) return; + + const eslintNode = node as unknown as Rule.Node; + const functionScope = sourceCode.getScope(eslintNode); + if ( + functionScope.references.length !== 0 || + functionScope.through.length !== 0 || + containsContextSensitiveNode(statement.argument) + ) { + return; + } + + const [variable] = sourceCode.getDeclaredVariables(eslintNode); + if (!variable || variable.references.length !== 1) return; + + const reference = variable.references[0] + .identifier as unknown as AstNode; + const call = reference.parent; + if ( + !call || + call.type !== "CallExpression" || + (call as AstNode & { callee: AstNode }).callee !== reference || + (call as AstNode & { arguments: AstNode[] }).arguments.length !== 0 || + (call as AstNode & { optional?: boolean }).optional || + (reference.range[0] >= node.range[0] && + reference.range[1] <= node.range[1]) + ) { + return; + } + + context.report({ + node: eslintNode, + messageId: "function", + fix(fixer) { + const expression = sourceCode.getText( + statement.argument! as unknown as Rule.Node, + ); + const replacement = + statement.argument!.type === "Literal" + ? expression + : `(${expression})`; + + return [ + fixer.replaceText(call as unknown as Rule.Node, replacement), + fixer.remove(eslintNode), + ]; + }, + }); + }, + }; + }, +}; diff --git a/utils/scripts/lint-packages.ts b/utils/scripts/lint-packages.ts index 941594c..0aea8c3 100644 --- a/utils/scripts/lint-packages.ts +++ b/utils/scripts/lint-packages.ts @@ -36,7 +36,7 @@ for (const targetDir of targetDirs) { const entry = relative(rootDir, fullPath); console.log(`Linting ${entry}...`); try { - execSync("pnpm run lint", { cwd: fullPath, stdio: "inherit" }); + execSync("pnpm run lint --fix", { cwd: fullPath, stdio: "inherit" }); console.log(`${entry} linted successfully.`); } catch { console.error(`Failed to lint ${entry}.`);