diff --git a/apps/electron/build/icons/icon.icns b/apps/electron/build/icons/icon.icns index a7f0a1e..7f3b591 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 deleted file mode 100644 index 424ebd1..0000000 Binary files a/apps/tauri/monochrome_cropped.png and /dev/null 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 cadb4d6..72c8d06 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,7 +56,6 @@ = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -48,7 +44,6 @@ 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) @@ -57,13 +52,6 @@ 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() @@ -107,7 +95,7 @@ class MtpForegroundService : Service() { ) return NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_sync) - .setContentTitle("Tensamin") + .setContentTitle("Tensamin background connection") .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 ba9422d..996daf0 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,7 +6,6 @@ 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 @@ -14,12 +13,7 @@ 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 { @@ -91,7 +85,6 @@ 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( @@ -114,36 +107,11 @@ 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(R.drawable.ic_notification_small) + .setSmallIcon(android.R.drawable.sym_action_chat) .setContentTitle(sender) .setContentText(body) - .setStyle(style) - .setShortcutId(shortcutId) - .setLocusId(LocusIdCompat(shortcutId)) - .setLargeIcon(avatarBitmap) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setCategory(Notification.CATEGORY_MESSAGE) .setAutoCancel(true) .setContentIntent(pendingIntent) @@ -153,9 +121,4 @@ 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 deleted file mode 100644 index bec055d..0000000 Binary files a/apps/tauri/src-tauri/gen/android/app/src/main/res/drawable/ic_notification_small.png and /dev/null differ diff --git a/apps/tauri/src-tauri/icons/icon.icns b/apps/tauri/src-tauri/icons/icon.icns index a7f0a1e..7f3b591 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 8131b57..5d49556 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()); - let app = builder + if let Err(error) = builder .invoke_handler(tauri::generate_handler![ mtp_backend::mtp_request, mtp_backend::mtp_status, @@ -26,7 +26,6 @@ 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, ]) @@ -46,18 +45,9 @@ pub fn run() { } Ok(()) }) - .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(); - } - } - }); + .run(tauri::generate_context!()) + { + eprintln!("error while running tauri application: {error}"); + panic!("error while running tauri application: {error}"); + } } diff --git a/apps/tauri/src-tauri/src/mtp_backend.rs b/apps/tauri/src-tauri/src/mtp_backend.rs index 7c65b03..8cbfb8d 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, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct MtpConfig { pub user_id: u64, @@ -135,11 +135,6 @@ 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; @@ -187,10 +182,6 @@ 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() @@ -455,19 +446,6 @@ 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}"); @@ -555,15 +533,7 @@ async fn notify_message( .or_else(|| user.get_str(DataType::Username)) .map(str::to_owned) .unwrap_or_else(|| format!("User {sender_id}")); - 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(), - )?; + android_notify(sender_id, &sender, &String::from_utf8_lossy(&plaintext)); Ok(()) } @@ -864,26 +834,6 @@ 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(()) @@ -903,13 +853,7 @@ 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, _: Option<&[u8]>) -> Result<(), String> { - Ok(()) -} -#[cfg(not(target_os = "android"))] -fn android_cancel_notification(_: u64) -> Result<(), String> { - Ok(()) -} +fn android_notify(_: u64, _: &str, _: &str) {} #[cfg(not(target_os = "android"))] fn android_is_ignoring_battery_optimizations() -> Result { Ok(true) @@ -1030,49 +974,24 @@ mod android { }); } - pub fn notify( - sender_id: u64, - sender: &str, - body: &str, - avatar: Option<&[u8]>, - ) -> Result<(), String> { - with_env(|env, host| { + pub fn notify(sender_id: u64, sender: &str, body: &str) { + let _ = 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;[B)V", + "(Landroid/content/Context;JLjava/lang/String;Ljava/lang/String;)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 { @@ -1165,7 +1084,7 @@ mod android { #[cfg(target_os = "android")] use android::{ - cancel_notification as android_cancel_notification, has_config as android_has_config, + 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 dcbb5c7..2094b73 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"; - - -export const deeplinkContext = createContext<{ +type DeeplinkContextValue = { deeplinks: readonly string[]; -} | undefined>( +}; + +export const deeplinkContext = createContext( undefined, ); diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 60d5ac9..6fbeae6 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, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; +import DeeplinkContext from "@tensamin/tauri/deeplinkHandler"; import NotificationsProvider from "@tensamin/notifications/context"; import TAuthWrapper from "@tensamin/tauth/context"; @@ -288,7 +288,6 @@ function AppShell() { - @@ -310,36 +309,6 @@ 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 f9bc102..df086d6 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -6,7 +6,6 @@ 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"; @@ -35,7 +34,6 @@ export default [ "react-hooks": reactHooks, tensamin: { rules: { - "inline-single-use-declarations": inlineSingleUseDeclarations, "no-react-namespace-import": noReactNamespaceImport, "no-window-location-reload": noWindowLocationReload, }, @@ -44,7 +42,6 @@ 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 f123834..a2fde87 100644 --- a/packages/call/src/mediaShare/controller.ts +++ b/packages/call/src/mediaShare/controller.ts @@ -17,6 +17,22 @@ 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, @@ -25,19 +41,7 @@ export function createMediaShareController({ startWatching, stopWatching, syncParticipantState, -}: { - room: Room; - getState: () => MediaShareStoreState; - setState: ( - updater: - | Partial - | ((state: MediaShareStoreState) => Partial), - ) => void; - getLocalParticipantId: () => number | null; - startWatching: (participantId: number) => void; - stopWatching: (participantId: number) => void; - syncParticipantState: () => void; -}) { +}: MediaShareControllerOptions) { 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 7263599..e88034e 100644 --- a/packages/call/src/mediaShare/tauri.ts +++ b/packages/call/src/mediaShare/tauri.ts @@ -8,16 +8,32 @@ import type { MediaShareSource, } from "./types"; +type MobileMediaApi = { + startScreenShare: (includeAudio: boolean) => void; + stopScreenShare: () => void; + requestCameraPermission: () => void; +}; + declare global { interface Window { - tensaminMobileMedia?: { - startScreenShare: (includeAudio: boolean) => void; - stopScreenShare: () => void; - requestCameraPermission: () => void; - }; + tensaminMobileMedia?: MobileMediaApi; } } +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; } @@ -148,12 +164,7 @@ async function startMobileScreen( }; const onFrame = (event: Event) => { - const detail = eventDetail<{ - data: string; - mimeType: string; - width: number; - height: number; - }>(event); + const detail = eventDetail(event); const image = new Image(); image.onload = () => { if (canvas.width !== detail.width || canvas.height !== detail.height) { @@ -168,14 +179,7 @@ async function startMobileScreen( }; const onAudio = (event: Event) => { - const bytes = decodeBase64( - eventDetail<{ - data: string; - sampleRate: number; - channelCount: number; - encoding: "pcm16le"; - }>(event).data, - ); + const bytes = decodeBase64(eventDetail(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 94ff030..a887516 100644 --- a/packages/call/src/speakingIndicator.ts +++ b/packages/call/src/speakingIndicator.ts @@ -10,19 +10,18 @@ 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< - number, - { - source: MediaStreamAudioSourceNode; - analyser: AnalyserNode; - track: MediaStreamTrack; - originalTrack?: MediaStreamTrack; - lastSpeakingTime: number; - isSpeaking: boolean; - } - >(); + private entries = new Map(); 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 c4f5198..b689bf5 100644 --- a/packages/call/src/speakingState.ts +++ b/packages/call/src/speakingState.ts @@ -1,10 +1,12 @@ import { create } from "zustand"; -const useSpeakingState = create<{ +type SpeakingState = { 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 687919b..5def2b6 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -51,6 +51,7 @@ setLogExtension( getLogger("tensamin"), ); +type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; type CallView = "preview" | "focused" | "grid"; type ProtocolCallSecret = NonNullable< z.infer["CallSecret"] @@ -62,9 +63,18 @@ 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, @@ -74,15 +84,44 @@ type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>; type RemoteVideoTrackSelector = Track.Kind | Track.Source; type Runtime = { - navigate: (options: { - to: string; - search?: Record; - }) => Promise; + navigate: NavigateFn; 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; @@ -1156,41 +1195,7 @@ async function ensureNoiseFilter( } } -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; -}>(() => ({ +export const useCall = create(() => ({ state: "closed", view: "preview", invitedUserId: null, diff --git a/packages/chat/src/components/gifPicker.tsx b/packages/chat/src/components/gifPicker.tsx index e44742b..fbec74b 100644 --- a/packages/chat/src/components/gifPicker.tsx +++ b/packages/chat/src/components/gifPicker.tsx @@ -34,25 +34,35 @@ 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< - string, - | Record< - string, - | { - url?: string; - width?: number; - height?: number; - } - | undefined - > - | undefined - >; + file?: Record; 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; @@ -95,11 +105,7 @@ async function fetchKlipyPage({ kind: KlipyKind; page: number; search: string; -}): Promise<{ - items: KlipyItem[]; - currentPage: number; - hasNext: boolean; -}> { +}): Promise { const params = new URLSearchParams({ page: String(page), per_page: String(pageSize), @@ -122,13 +128,7 @@ async function fetchKlipyPage({ throw new Error(`Klipy request failed with status ${response.status}`); } - const body = (await response.json()) as { - data?: { - data?: KlipyItem[]; - current_page?: number; - has_next?: boolean; - }; - }; + const body = (await response.json()) as KlipyResponse; const data = body.data; return { diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index e54235d..5d6088a 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -66,13 +66,6 @@ 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 5b6e796..78c1bb2 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -145,6 +145,8 @@ type SendMessageGet = ( data: { SendTime: number }, ) => Promise<{ data: RawMessage }>; +type GetChatSecret = (userId: number) => Promise; + type StoredDraftState = ChatDraft & { accountId: number; userId: number; @@ -202,7 +204,7 @@ export async function fetchReplyMessage({ ownId: number; chatUserId: number; send: SendMessageGet; - getChatSecret: (userId: number) => Promise; + getChatSecret: GetChatSecret; }) { const message = await getMessage({ sendTime: replyTo, diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 0587d54..8b3778e 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -21,6 +21,12 @@ import { } from "./values"; import Wrapper from "@tensamin/user/wrapper"; +type MessageChunk = { + key: string; + messages: Array; + startIndex: number; +}; + function shouldFetchPreviousPage({ entry, hasNextPage, @@ -102,11 +108,7 @@ function buildMessageChunks( keyPrefix: string, startOffset = 0, ) { - const chunks: { - key: string; - messages: Array; - startIndex: number; - }[] = []; + const chunks: MessageChunk[] = []; for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) { const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW); @@ -280,7 +282,7 @@ export default function Screen() { getItemKey, estimateSize, overscan: 2, - paddingStart: composerHeight + 20, + paddingStart: composerHeight + 8, }); 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 158b5b0..f1ed5ab 100644 --- a/packages/markdown/src/emojiData.ts +++ b/packages/markdown/src/emojiData.ts @@ -1,5 +1,7 @@ import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json"; +type ShortcodeValue = string | string[]; + export type EmojiDefinition = { aliases: readonly string[]; hexcode: string; @@ -15,7 +17,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 e16c84d..58ebe9f 100644 --- a/packages/markdown/src/input.tsx +++ b/packages/markdown/src/input.tsx @@ -76,6 +76,10 @@ 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; @@ -95,6 +99,11 @@ 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" }); @@ -434,9 +443,7 @@ export default function Input(props: InputProps) { props.paddingX, Boolean(props.styled), ), - } as CSSProperties & { - "--tm-md-content-padding"?: string; - } + } as InputStyle } /> ); @@ -827,10 +834,7 @@ function buildDecorations(view: EditorView): DecorationSet { function addHiddenToken( builder: Range[], selections: ReadonlyArray<{ from: number; to: number }>, - token: { - from: number; - to: number; - }, + token: TokenRange, ): void { if (token.from >= token.to) return; diff --git a/packages/markdown/src/markdown.tsx b/packages/markdown/src/markdown.tsx index bc64692..95b4a87 100644 --- a/packages/markdown/src/markdown.tsx +++ b/packages/markdown/src/markdown.tsx @@ -1,11 +1,4 @@ -import { - Fragment, - useEffect, - useRef, - useState, - type ReactElement, - type ReactNode, -} from "react"; +import { Fragment, type ReactElement, type ReactNode } from "react"; import Emoji from "./emoji"; import { findEmojiShortcodes } from "./emojiData"; @@ -30,11 +23,43 @@ 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[]; @@ -42,140 +67,17 @@ type TableBlock = { }; type MarkdownBlock = - | { - 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[]; - } + | ParagraphBlock + | HeadingBlock + | HrBlock + | BlockQuoteBlock + | CodeBlock + | ListBlock | 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. @@ -527,7 +429,11 @@ function renderInline(nodes: InlineNode[]): ReactNode[] { } if (node.type === "code") { - return ; + return ( + + {node.value} + + ); } if (node.type === "link") { @@ -617,12 +523,11 @@ export function renderBlocks(blocks: MarkdownBlock[]): ReactElement { if (block.type === "code") { return ( - +
+              
+                {block.code}
+              
+            
); } @@ -761,7 +666,7 @@ function readTable( } const markdownStyles = ` -.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; } +.tm-md-root { color: hsl(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; } @@ -771,15 +676,13 @@ 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: 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-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-strong { font-weight: 700; } .tm-md-em { font-style: italic; } .tm-md-del { text-decoration: line-through; } -.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; } +.tm-md-link { color: hsl(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; } @@ -788,7 +691,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: var(--muted); font-weight: 600; } +.tm-md-table th { background: hsl(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); } @@ -796,10 +699,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: var(--foreground); } +.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(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: 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: hsl(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; } @@ -823,15 +726,10 @@ export function ensureMarkdownStyles(): void { if (typeof document === "undefined") return; const styleId = "tensamin-markdown-styles"; - let style = document.getElementById(styleId) as HTMLStyleElement | null; + if (document.getElementById(styleId)) return; - if (!style) { - style = document.createElement("style"); - style.id = styleId; - document.head.appendChild(style); - } - - if (style.textContent !== markdownStyles) { - style.textContent = markdownStyles; - } + const style = document.createElement("style"); + style.id = styleId; + style.textContent = markdownStyles; + document.head.appendChild(style); } diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index 69010a9..2c9f1e5 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -631,6 +631,16 @@ 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; @@ -703,16 +713,7 @@ function TauriProvider(props: { let disposed = false; let unlisten: UnlistenFn | undefined; void (async () => { - unlisten = await listen< - | { kind: "state"; snapshot: NativeSnapshot } - | { kind: "message"; generation: number; message: unknown } - | { - kind: "log"; - level: number; - message: string; - details?: unknown; - } - >("mtp://event", ({ payload }) => { + unlisten = await listen("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 a63b906..453d4e7 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 { invoke, isTauri } from "@tauri-apps/api/core"; +import { isTauri } from "@tauri-apps/api/core"; import { isPermissionGranted as isTauriNotificationPermissionGranted, requestPermission as requestTauriNotificationPermission, @@ -108,30 +108,7 @@ export default function Provider(props: { children: React.ReactNode }) { (await requestTauriNotificationPermission()) === "granted"; if (permissionGranted) { - 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 }); - } + 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 5efa9c9..da7971e 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: z.infer; + docs: LegalDocs; acceptedPP: boolean; acceptedTOS: boolean; includeLegal: boolean; diff --git a/packages/onboarding/src/pages/legal.tsx b/packages/onboarding/src/pages/legal.tsx index 86c8133..495d143 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: z.infer; + docs: LegalDocs; initiallyAcceptedPP: boolean; initiallyAcceptedTOS: boolean; onAccept: () => Promise; diff --git a/packages/settings/src/components.tsx b/packages/settings/src/components.tsx index f2ca317..315ba78 100644 --- a/packages/settings/src/components.tsx +++ b/packages/settings/src/components.tsx @@ -5,7 +5,9 @@ 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; @@ -19,9 +21,7 @@ export function Switch({ id, }: { label: React.ReactNode; - id: keyof typeof settingsStorageDefaults & ({ - [K in keyof Storage]: Storage[K] extends boolean ? K : never; -}[keyof Storage]); + id: keyof typeof settingsStorageDefaults & BooleanStorageKey; }) { 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 8480258..4dc5cfd 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -143,7 +143,25 @@ 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({ @@ -454,25 +472,7 @@ export interface Storage extends SettingsStorageDefaults { call_mute_range_start: number; call_mute_range_end: number; theme_color: string; - theme_palette: Record< - | "base00" - | "base01" - | "base02" - | "base03" - | "base04" - | "base05" - | "base06" - | "base07" - | "base08" - | "base09" - | "base0A" - | "base0B" - | "base0C" - | "base0D" - | "base0E" - | "base0F", - string -> | null; + theme_palette: Base16Palette | 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 6799966..75550cd 100644 --- a/packages/shared/src/desktopMedia.tsx +++ b/packages/shared/src/desktopMedia.tsx @@ -22,11 +22,7 @@ export type DesktopScreenShareCapabilities = { hasReliableSystemAudio: boolean; }; - - -declare global { - interface Window { - tensaminDesktop?: { +type ElectronDesktopApi = { media?: { getScreenShareCapabilities?: () => Promise; listScreenShareSources?: () => Promise; @@ -57,6 +53,10 @@ declare global { clear?: () => Promise; }; }; + +declare global { + interface Window { + tensaminDesktop?: ElectronDesktopApi; } } diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts index 6fd4d2a..0c3116b 100644 --- a/packages/shared/src/settings.ts +++ b/packages/shared/src/settings.ts @@ -1,14 +1,14 @@ type StringKeyOf = Extract; - - -export type SettingsSchema = Record< - string, - Record> +}; + +export type SettingsSchema = Record< + string, + Record> >; const settings = { diff --git a/utils/eslint-rules/index.ts b/utils/eslint-rules/index.ts index ea4eea7..8dee264 100644 --- a/utils/eslint-rules/index.ts +++ b/utils/eslint-rules/index.ts @@ -72,211 +72,3 @@ 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 0aea8c3..941594c 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 --fix", { cwd: fullPath, stdio: "inherit" }); + execSync("pnpm run lint", { cwd: fullPath, stdio: "inherit" }); console.log(`${entry} linted successfully.`); } catch { console.error(`Failed to lint ${entry}.`);