dev #27
35 changed files with 777 additions and 287 deletions
(feat): improved mobile notifications
(feat): add lint rules (feat): improve markdown inline code box
commit
4a841de073
Binary file not shown.
BIN
apps/tauri/monochrome_cropped.png
Normal file
BIN
apps/tauri/monochrome_cropped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -56,6 +56,7 @@
|
|||
<service
|
||||
android:name=".MtpForegroundService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
|
|
|
|||
|
|
@ -65,11 +65,11 @@ class MainActivity : TauriActivity() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
super.onCreate(savedInstanceState)
|
||||
NativeMtpBridge.nativeAttach(applicationContext)
|
||||
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
|
||||
NativeMtpBridge.startService(this)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
NativeMtpBridge.nativeAttach(applicationContext)
|
||||
installKeyboardResizeWorkaround()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import android.os.IBinder
|
|||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class MtpForegroundService : Service() {
|
||||
private var started = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
|
@ -23,6 +25,8 @@ class MtpForegroundService : Service() {
|
|||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (started) return START_STICKY
|
||||
|
||||
createChannel(this)
|
||||
val notification = buildNotification(this, "Connecting")
|
||||
if (Build.VERSION.SDK_INT >= 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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MTPConnection>, 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<String>,
|
||||
) -> Result<bool, String> {
|
||||
#[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<bool, String> {
|
||||
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<bool, String> {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<DeeplinkContextValue | undefined>(
|
||||
|
||||
export const deeplinkContext = createContext<{
|
||||
deeplinks: readonly string[];
|
||||
} | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<DesktopMediaProvider>
|
||||
<MTPProvider>
|
||||
<CacheSync />
|
||||
<DeeplinkNavigator />
|
||||
<Session>
|
||||
<UserProvider>
|
||||
<CallInit />
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,22 +17,6 @@ type MediaShareStoreState = {
|
|||
cameraSession: LocalMediaShareSession | null;
|
||||
};
|
||||
|
||||
type MediaShareStoreSetState = (
|
||||
updater:
|
||||
| Partial<MediaShareStoreState>
|
||||
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
|
||||
) => 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<MediaShareStoreState>
|
||||
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
|
||||
) => void;
|
||||
getLocalParticipantId: () => number | null;
|
||||
startWatching: (participantId: number) => void;
|
||||
stopWatching: (participantId: number) => void;
|
||||
syncParticipantState: () => void;
|
||||
}) {
|
||||
function getSession(kind: MediaShareKind) {
|
||||
return kind === "screen"
|
||||
? getState().screenShareSession
|
||||
|
|
|
|||
|
|
@ -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<T>(event: Event): T {
|
||||
return (event as CustomEvent<T>).detail;
|
||||
}
|
||||
|
|
@ -164,7 +148,12 @@ async function startMobileScreen(
|
|||
};
|
||||
|
||||
const onFrame = (event: Event) => {
|
||||
const detail = eventDetail<FrameDetail>(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<AudioDetail>(event).data);
|
||||
const bytes = decodeBase64(
|
||||
eventDetail<{
|
||||
data: string;
|
||||
sampleRate: number;
|
||||
channelCount: number;
|
||||
encoding: "pcm16le";
|
||||
}>(event).data,
|
||||
);
|
||||
const samples = new Int16Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
|
|
|
|||
|
|
@ -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<number, AnalyserEntry>();
|
||||
private entries = new Map<
|
||||
number,
|
||||
{
|
||||
source: MediaStreamAudioSourceNode;
|
||||
analyser: AnalyserNode;
|
||||
track: MediaStreamTrack;
|
||||
originalTrack?: MediaStreamTrack;
|
||||
lastSpeakingTime: number;
|
||||
isSpeaking: boolean;
|
||||
}
|
||||
>();
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private deaf = false;
|
||||
private gateThresholdStart = -50;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
type SpeakingState = {
|
||||
const useSpeakingState = create<{
|
||||
speakingParticipantIds: Set<number>;
|
||||
lastSpeakingParticipantId: number | null;
|
||||
micGated: boolean;
|
||||
};
|
||||
|
||||
const useSpeakingState = create<SpeakingState>(() => ({
|
||||
}>(() => ({
|
||||
speakingParticipantIds: new Set(),
|
||||
lastSpeakingParticipantId: null,
|
||||
micGated: false,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ setLogExtension(
|
|||
getLogger("tensamin"),
|
||||
);
|
||||
|
||||
type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting";
|
||||
type CallView = "preview" | "focused" | "grid";
|
||||
type ProtocolCallSecret = NonNullable<
|
||||
z.infer<typeof mtp.CallInvite.response>["CallSecret"]
|
||||
|
|
@ -63,18 +62,9 @@ type WrappedCallSecret = {
|
|||
kemCiphertext: Uint8Array;
|
||||
wrappingScheme: string;
|
||||
};
|
||||
type IncomingCallInvite = {
|
||||
callId: string;
|
||||
callSecret: WrappedCallSecret;
|
||||
senderId: number;
|
||||
};
|
||||
type CurrentCallData =
|
||||
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
|
||||
|
||||
type NavigateFn = (options: {
|
||||
to: string;
|
||||
search?: Record<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
type SendFn = (
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
|
|
@ -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<string, unknown>;
|
||||
}) => Promise<void>;
|
||||
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<HTMLDivElement | null> | 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<CallStore>(() => ({
|
||||
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<HTMLDivElement | null> | null;
|
||||
runtime: Runtime | null;
|
||||
lastFocusedParticipantId: number | null;
|
||||
}>(() => ({
|
||||
state: "closed",
|
||||
view: "preview",
|
||||
invitedUserId: null,
|
||||
|
|
|
|||
|
|
@ -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<string, KlipyMediaFile | undefined>;
|
||||
|
||||
type KlipyItem = {
|
||||
id: number | string;
|
||||
title?: string;
|
||||
file?: Record<string, KlipyMediaFormats | undefined>;
|
||||
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<KlipyPage> {
|
||||
}): 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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -145,8 +145,6 @@ type SendMessageGet = (
|
|||
data: { SendTime: number },
|
||||
) => Promise<{ data: RawMessage }>;
|
||||
|
||||
type GetChatSecret = (userId: number) => Promise<Uint8Array | null>;
|
||||
|
||||
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<Uint8Array | null>;
|
||||
}) {
|
||||
const message = await getMessage({
|
||||
sendTime: replyTo,
|
||||
|
|
|
|||
|
|
@ -21,12 +21,6 @@ import {
|
|||
} from "./values";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
|
||||
type MessageChunk = {
|
||||
key: string;
|
||||
messages: Array<RawMessage | LiveMessage>;
|
||||
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<RawMessage | LiveMessage>;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, ShortcodeValue>,
|
||||
shortcodeData as Record<string, string | string[]>,
|
||||
).map(([hexcode, value]) => {
|
||||
const aliases = Array.isArray(value) ? value : [value];
|
||||
const name = aliases[0];
|
||||
|
|
|
|||
|
|
@ -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<Decoration>[],
|
||||
selections: ReadonlyArray<{ from: number; to: number }>,
|
||||
token: TokenRange,
|
||||
token: {
|
||||
from: number;
|
||||
to: number;
|
||||
},
|
||||
): void {
|
||||
if (token.from >= token.to) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -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]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
|
||||
|
||||
function CopiedIndicator({
|
||||
block,
|
||||
visible,
|
||||
}: {
|
||||
block: boolean;
|
||||
visible: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`pointer-events-none inline-flex align-middle text-foreground transition-opacity duration-200 ease-out ${block ? "mt-3 shrink-0" : "ml-1"} ${visible ? "opacity-100" : "opacity-0"}`}
|
||||
aria-live="polite"
|
||||
aria-hidden={!visible}
|
||||
>
|
||||
<svg
|
||||
className="size-3.5"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="3.5"
|
||||
width="10"
|
||||
height="11"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M6 4V2.75C6 2.06 6.56 1.5 7.25 1.5h1.5c.69 0 1.25.56 1.25 1.25V4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Copied</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyableCode({
|
||||
block = false,
|
||||
language,
|
||||
value,
|
||||
}: {
|
||||
block?: boolean;
|
||||
language?: string;
|
||||
value: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | 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 = (
|
||||
<code
|
||||
className={block ? "tm-md-codeblock" : "tm-md-code"}
|
||||
data-language={language}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => void copy()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
void copy();
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</code>
|
||||
);
|
||||
|
||||
if (block) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-1">
|
||||
<pre className="tm-md-pre min-w-0 flex-1">{code}</pre>
|
||||
<CopiedIndicator block visible={copied} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{code}
|
||||
<CopiedIndicator block={false} visible={copied} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes parseInlineNodes.
|
||||
* @param input Parameter input.
|
||||
|
|
@ -429,11 +527,7 @@ function renderInline(nodes: InlineNode[]): ReactNode[] {
|
|||
}
|
||||
|
||||
if (node.type === "code") {
|
||||
return (
|
||||
<code key={index} className="tm-md-code">
|
||||
{node.value}
|
||||
</code>
|
||||
);
|
||||
return <CopyableCode key={index} value={node.value} />;
|
||||
}
|
||||
|
||||
if (node.type === "link") {
|
||||
|
|
@ -523,11 +617,12 @@ export function renderBlocks(blocks: MarkdownBlock[]): ReactElement {
|
|||
|
||||
if (block.type === "code") {
|
||||
return (
|
||||
<pre key={blockIndex} className="tm-md-pre">
|
||||
<code className="tm-md-codeblock" data-language={block.language}>
|
||||
{block.code}
|
||||
</code>
|
||||
</pre>
|
||||
<CopyableCode
|
||||
key={blockIndex}
|
||||
block
|
||||
language={block.language}
|
||||
value={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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NativeEvent>("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);
|
||||
|
|
|
|||
|
|
@ -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<boolean>(
|
||||
"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();
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ export {
|
|||
type OnboardingStepControls,
|
||||
} from "@methanium/ui";
|
||||
|
||||
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
||||
|
||||
|
||||
interface GateState {
|
||||
docs: LegalDocs;
|
||||
docs: z.infer<typeof legalDocsSchema>;
|
||||
acceptedPP: boolean;
|
||||
acceptedTOS: boolean;
|
||||
includeLegal: boolean;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { z } from "zod";
|
|||
|
||||
import { useOnboardingStep } from "@methanium/ui";
|
||||
|
||||
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
||||
|
||||
|
||||
export default function LegalPage({
|
||||
docs,
|
||||
|
|
@ -13,7 +13,7 @@ export default function LegalPage({
|
|||
initiallyAcceptedTOS,
|
||||
onAccept,
|
||||
}: {
|
||||
docs: LegalDocs;
|
||||
docs: z.infer<typeof legalDocsSchema>;
|
||||
initiallyAcceptedPP: boolean;
|
||||
initiallyAcceptedTOS: boolean;
|
||||
onAccept: () => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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<boolean>(settingsStorageDefaults[id]);
|
||||
|
|
|
|||
|
|
@ -143,25 +143,7 @@ export type Contacts = z.infer<typeof authPayload.shape.Contacts>;
|
|||
export type Communities = z.infer<typeof authPayload.shape.Communities>;
|
||||
export type Calls = z.infer<typeof authPayload.shape.Calls>;
|
||||
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ export type DesktopScreenShareCapabilities = {
|
|||
hasReliableSystemAudio: boolean;
|
||||
};
|
||||
|
||||
type ElectronDesktopApi = {
|
||||
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
tensaminDesktop?: {
|
||||
media?: {
|
||||
getScreenShareCapabilities?: () => Promise<DesktopScreenShareCapabilities>;
|
||||
listScreenShareSources?: () => Promise<DesktopScreenShareSource[]>;
|
||||
|
|
@ -53,10 +57,6 @@ type ElectronDesktopApi = {
|
|||
clear?: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
tensaminDesktop?: ElectronDesktopApi;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
type StringKeyOf<T> = Extract<keyof T, string>;
|
||||
|
||||
type SettingDefinition = {
|
||||
display: string;
|
||||
type: string;
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
|
||||
export type SettingsSchema = Record<
|
||||
string,
|
||||
Record<string, Record<string, SettingDefinition>>
|
||||
Record<string, Record<string, {
|
||||
display: string;
|
||||
type: string;
|
||||
default?: unknown;
|
||||
}>>
|
||||
>;
|
||||
|
||||
const settings = {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}.`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue