(feat): improved mobile notifications

(feat): add lint rules
(feat): improve markdown inline code box
This commit is contained in:
Alois 2026-08-05 21:41:55 +02:00
commit 4a841de073
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
35 changed files with 777 additions and 287 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -56,6 +56,7 @@
<service <service
android:name=".MtpForegroundService" android:name=".MtpForegroundService"
android:exported="false" android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="specialUse"> android:foregroundServiceType="specialUse">
<property <property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"

View file

@ -65,11 +65,11 @@ class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
super.onCreate(savedInstanceState)
NativeMtpBridge.nativeAttach(applicationContext)
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) { if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
NativeMtpBridge.startService(this) NativeMtpBridge.startService(this)
} }
super.onCreate(savedInstanceState)
NativeMtpBridge.nativeAttach(applicationContext)
installKeyboardResizeWorkaround() installKeyboardResizeWorkaround()
} }

View file

@ -13,6 +13,8 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
class MtpForegroundService : Service() { class MtpForegroundService : Service() {
private var started = false
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -23,6 +25,8 @@ class MtpForegroundService : Service() {
return START_NOT_STICKY return START_NOT_STICKY
} }
if (started) return START_STICKY
createChannel(this) createChannel(this)
val notification = buildNotification(this, "Connecting") val notification = buildNotification(this, "Connecting")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@ -44,6 +48,7 @@ class MtpForegroundService : Service() {
try { try {
NativeMtpBridge.nativeAttach(applicationContext) NativeMtpBridge.nativeAttach(applicationContext)
NativeMtpBridge.nativeStart(config) NativeMtpBridge.nativeStart(config)
started = true
NativeMtpBridge.log(2, "Started MTP foreground service") NativeMtpBridge.log(2, "Started MTP foreground service")
} catch (error: Throwable) { } catch (error: Throwable) {
NativeMtpBridge.log(0, "Failed to start MTP foreground service", error) NativeMtpBridge.log(0, "Failed to start MTP foreground service", error)
@ -52,6 +57,13 @@ class MtpForegroundService : Service() {
return START_STICKY 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() { override fun onDestroy() {
if (!MtpSecureStore.isEnabled(this)) NativeMtpBridge.nativeStop() if (!MtpSecureStore.isEnabled(this)) NativeMtpBridge.nativeStop()
super.onDestroy() super.onDestroy()
@ -95,7 +107,7 @@ class MtpForegroundService : Service() {
) )
return NotificationCompat.Builder(context, CHANNEL_ID) return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync) .setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Tensamin background connection") .setContentTitle("Tensamin")
.setContentText(status) .setContentText(status)
.setContentIntent(openIntent) .setContentIntent(openIntent)
.setOngoing(true) .setOngoing(true)

View file

@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
@ -13,7 +14,12 @@ import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.LocusIdCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
@Keep @Keep
object NativeMtpBridge { object NativeMtpBridge {
@ -85,6 +91,7 @@ object NativeMtpBridge {
senderId: Long, senderId: Long,
sender: String, sender: String,
body: String, body: String,
avatar: ByteArray,
) { ) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.getSystemService(NotificationManager::class.java).createNotificationChannel( context.getSystemService(NotificationManager::class.java).createNotificationChannel(
@ -107,11 +114,36 @@ object NativeMtpBridge {
openIntent, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, 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) val notification = NotificationCompat.Builder(context, MESSAGE_CHANNEL)
.setSmallIcon(android.R.drawable.sym_action_chat) .setSmallIcon(R.drawable.ic_notification_small)
.setContentTitle(sender) .setContentTitle(sender)
.setContentText(body) .setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setStyle(style)
.setShortcutId(shortcutId)
.setLocusId(LocusIdCompat(shortcutId))
.setLargeIcon(avatarBitmap)
.setCategory(Notification.CATEGORY_MESSAGE) .setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
@ -121,4 +153,9 @@ object NativeMtpBridge {
.notify(senderId.hashCode(), notification) .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.

View file

@ -17,7 +17,7 @@ pub fn run() {
#[cfg(any(target_os = "ios", target_os = "android"))] #[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init()); let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
if let Err(error) = builder let app = builder
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
mtp_backend::mtp_request, mtp_backend::mtp_request,
mtp_backend::mtp_status, mtp_backend::mtp_status,
@ -26,6 +26,7 @@ pub fn run() {
mtp_backend::mtp_load_keyring, mtp_backend::mtp_load_keyring,
mtp_backend::mtp_set_enabled, mtp_backend::mtp_set_enabled,
mtp_backend::mtp_set_ui_visible, mtp_backend::mtp_set_ui_visible,
mtp_backend::mtp_post_message_notification,
mtp_backend::mtp_is_ignoring_battery_optimizations, mtp_backend::mtp_is_ignoring_battery_optimizations,
mtp_backend::mtp_request_battery_exemption, mtp_backend::mtp_request_battery_exemption,
]) ])
@ -45,9 +46,18 @@ pub fn run() {
} }
Ok(()) Ok(())
}) })
.run(tauri::generate_context!()) .build(tauri::generate_context!())
{ .expect("error while building tauri application");
eprintln!("error while running tauri application: {error}");
panic!("error while running tauri application: {error}"); 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();
}
}
});
} }

View file

@ -56,7 +56,7 @@ Zwkt6K2EOMmh1nvEzl83eMLYcod4GCl3b0J1Nn0CMBNYmEQJb4CEG5WoOe7aRn/L\n\
VKu6saHmHEynI7ysIPd8zQsK1HdmhlHKlw9Z5GpGvA==\n\ VKu6saHmHEynI7ysIPd8zQsK1HdmhlHKlw9Z5GpGvA==\n\
-----END CERTIFICATE-----\n"; -----END CERTIFICATE-----\n";
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MtpConfig { pub struct MtpConfig {
pub user_id: u64, pub user_id: u64,
@ -135,6 +135,11 @@ impl MtpManager {
pub fn configure_and_start(&'static self, config: MtpConfig) { pub fn configure_and_start(&'static self, config: MtpConfig) {
let _guard = self.start_lock.lock().expect("start lock poisoned"); 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.config.write().expect("config lock poisoned") = Some(config);
self.enabled.store(true, Ordering::SeqCst); self.enabled.store(true, Ordering::SeqCst);
let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
@ -182,6 +187,10 @@ impl MtpManager {
self.ui_visible.store(visible, Ordering::SeqCst); self.ui_visible.store(visible, Ordering::SeqCst);
} }
pub fn is_enabled(&self) -> bool {
self.enabled.load(Ordering::SeqCst)
}
pub fn snapshot(&self) -> MtpSnapshot { pub fn snapshot(&self) -> MtpSnapshot {
self.snapshot self.snapshot
.read() .read()
@ -446,6 +455,19 @@ async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: Com
message, 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 frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) {
if let Err(error) = notify_message(connection, &frame).await { if let Err(error) = notify_message(connection, &frame).await {
eprintln!("failed to create background message notification: {error}"); eprintln!("failed to create background message notification: {error}");
@ -533,7 +555,15 @@ async fn notify_message(
.or_else(|| user.get_str(DataType::Username)) .or_else(|| user.get_str(DataType::Username))
.map(str::to_owned) .map(str::to_owned)
.unwrap_or_else(|| format!("User {sender_id}")); .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(()) Ok(())
} }
@ -834,6 +864,26 @@ pub fn mtp_set_ui_visible(visible: bool) {
manager().set_ui_visible(visible); 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"))] #[cfg(not(target_os = "android"))]
fn android_store_config(_: &str) -> Result<(), String> { fn android_store_config(_: &str) -> Result<(), String> {
Ok(()) Ok(())
@ -853,7 +903,13 @@ fn android_set_enabled(_: bool) -> Result<(), String> {
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
fn android_status(_: &str) {} fn android_status(_: &str) {}
#[cfg(not(target_os = "android"))] #[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"))] #[cfg(not(target_os = "android"))]
fn android_is_ignoring_battery_optimizations() -> Result<bool, String> { fn android_is_ignoring_battery_optimizations() -> Result<bool, String> {
Ok(true) Ok(true)
@ -974,24 +1030,49 @@ mod android {
}); });
} }
pub fn notify(sender_id: u64, sender: &str, body: &str) { pub fn notify(
let _ = with_env(|env, host| { 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 sender = env.new_string(sender).map_err(|e| e.to_string())?;
let body = env.new_string(body).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( env.call_method(
host.bridge.as_obj(), host.bridge.as_obj(),
"postMessageNotification", "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::Object(host.context.as_obj()),
JValue::Long(sender_id as i64), JValue::Long(sender_id as i64),
JValue::Object(&sender), JValue::Object(&sender),
JValue::Object(&body), JValue::Object(&body),
JValue::Object(&avatar),
], ],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(()) 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> { pub fn is_ignoring_battery_optimizations() -> Result<bool, String> {
@ -1084,7 +1165,7 @@ mod android {
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use 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, is_ignoring_battery_optimizations as android_is_ignoring_battery_optimizations,
notify as android_notify, request_battery_exemption as android_request_battery_exemption, notify as android_notify, request_battery_exemption as android_request_battery_exemption,
set_enabled as android_set_enabled, status as android_status, set_enabled as android_set_enabled, status as android_status,

View file

@ -9,11 +9,11 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@methanium/ui"; 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, undefined,
); );

View file

@ -27,7 +27,7 @@ import { useCall, useInitializeCall } from "@tensamin/call/store";
import { useIsSpeaking } from "@tensamin/call/speakingState"; import { useIsSpeaking } from "@tensamin/call/speakingState";
import { Provider as MTPProvider } from "@tensamin/mtp"; import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context"; 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 NotificationsProvider from "@tensamin/notifications/context";
import TAuthWrapper from "@tensamin/tauth/context"; import TAuthWrapper from "@tensamin/tauth/context";
@ -288,6 +288,7 @@ function AppShell() {
<DesktopMediaProvider> <DesktopMediaProvider>
<MTPProvider> <MTPProvider>
<CacheSync /> <CacheSync />
<DeeplinkNavigator />
<Session> <Session>
<UserProvider> <UserProvider>
<CallInit /> <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) { function createCallTrayIcon(color: string, speaking: boolean) {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = 32; canvas.width = 32;

View file

@ -6,6 +6,7 @@ import * as tsParser from "@typescript-eslint/parser";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import {
inlineSingleUseDeclarations,
noReactNamespaceImport, noReactNamespaceImport,
noWindowLocationReload, noWindowLocationReload,
} from "./utils/eslint-rules/index.js"; } from "./utils/eslint-rules/index.js";
@ -34,6 +35,7 @@ export default [
"react-hooks": reactHooks, "react-hooks": reactHooks,
tensamin: { tensamin: {
rules: { rules: {
"inline-single-use-declarations": inlineSingleUseDeclarations,
"no-react-namespace-import": noReactNamespaceImport, "no-react-namespace-import": noReactNamespaceImport,
"no-window-location-reload": noWindowLocationReload, "no-window-location-reload": noWindowLocationReload,
}, },
@ -42,6 +44,7 @@ export default [
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off", "react-hooks/set-state-in-effect": "off",
"tensamin/inline-single-use-declarations": "error",
"tensamin/no-react-namespace-import": "error", "tensamin/no-react-namespace-import": "error",
"tensamin/no-window-location-reload": "error", "tensamin/no-window-location-reload": "error",
}, },

View file

@ -17,22 +17,6 @@ type MediaShareStoreState = {
cameraSession: LocalMediaShareSession | null; 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({ export function createMediaShareController({
room, room,
getState, getState,
@ -41,7 +25,19 @@ export function createMediaShareController({
startWatching, startWatching,
stopWatching, stopWatching,
syncParticipantState, 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) { function getSession(kind: MediaShareKind) {
return kind === "screen" return kind === "screen"
? getState().screenShareSession ? getState().screenShareSession

View file

@ -8,32 +8,16 @@ import type {
MediaShareSource, MediaShareSource,
} from "./types"; } from "./types";
type MobileMediaApi = {
startScreenShare: (includeAudio: boolean) => void;
stopScreenShare: () => void;
requestCameraPermission: () => void;
};
declare global { declare global {
interface Window { 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 { function eventDetail<T>(event: Event): T {
return (event as CustomEvent<T>).detail; return (event as CustomEvent<T>).detail;
} }
@ -164,7 +148,12 @@ async function startMobileScreen(
}; };
const onFrame = (event: Event) => { 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(); const image = new Image();
image.onload = () => { image.onload = () => {
if (canvas.width !== detail.width || canvas.height !== detail.height) { if (canvas.width !== detail.width || canvas.height !== detail.height) {
@ -179,7 +168,14 @@ async function startMobileScreen(
}; };
const onAudio = (event: Event) => { 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( const samples = new Int16Array(
bytes.buffer, bytes.buffer,
bytes.byteOffset, bytes.byteOffset,

View file

@ -10,18 +10,19 @@ const SPEAKING_HANGTIME_MS = 500;
const ANALYSIS_INTERVAL_MS = 30; const ANALYSIS_INTERVAL_MS = 30;
const FFT_SIZE = 256; const FFT_SIZE = 256;
type AnalyserEntry = {
source: MediaStreamAudioSourceNode;
analyser: AnalyserNode;
track: MediaStreamTrack;
originalTrack?: MediaStreamTrack;
lastSpeakingTime: number;
isSpeaking: boolean;
};
class SpeakingDetector { class SpeakingDetector {
private audioContext: AudioContext | null = null; 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 intervalId: ReturnType<typeof setInterval> | null = null;
private deaf = false; private deaf = false;
private gateThresholdStart = -50; private gateThresholdStart = -50;

View file

@ -1,12 +1,10 @@
import { create } from "zustand"; import { create } from "zustand";
type SpeakingState = { const useSpeakingState = create<{
speakingParticipantIds: Set<number>; speakingParticipantIds: Set<number>;
lastSpeakingParticipantId: number | null; lastSpeakingParticipantId: number | null;
micGated: boolean; micGated: boolean;
}; }>(() => ({
const useSpeakingState = create<SpeakingState>(() => ({
speakingParticipantIds: new Set(), speakingParticipantIds: new Set(),
lastSpeakingParticipantId: null, lastSpeakingParticipantId: null,
micGated: false, micGated: false,

View file

@ -51,7 +51,6 @@ setLogExtension(
getLogger("tensamin"), getLogger("tensamin"),
); );
type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting";
type CallView = "preview" | "focused" | "grid"; type CallView = "preview" | "focused" | "grid";
type ProtocolCallSecret = NonNullable< type ProtocolCallSecret = NonNullable<
z.infer<typeof mtp.CallInvite.response>["CallSecret"] z.infer<typeof mtp.CallInvite.response>["CallSecret"]
@ -63,18 +62,9 @@ type WrappedCallSecret = {
kemCiphertext: Uint8Array; kemCiphertext: Uint8Array;
wrappingScheme: string; wrappingScheme: string;
}; };
type IncomingCallInvite = {
callId: string;
callSecret: WrappedCallSecret;
senderId: number;
};
type CurrentCallData = type CurrentCallData =
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null; (z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
type NavigateFn = (options: {
to: string;
search?: Record<string, unknown>;
}) => Promise<void>;
type SendFn = ( type SendFn = (
type: string, type: string,
data: Record<string, unknown>, data: Record<string, unknown>,
@ -84,44 +74,15 @@ type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>;
type RemoteVideoTrackSelector = Track.Kind | Track.Source; type RemoteVideoTrackSelector = Track.Kind | Track.Source;
type Runtime = { type Runtime = {
navigate: NavigateFn; navigate: (options: {
to: string;
search?: Record<string, unknown>;
}) => Promise<void>;
send: SendFn; send: SendFn;
load: LoadFn; load: LoadFn;
getUser: GetUserFn; 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 _keyProvider: ExternalE2EEKeyProvider | null = null;
let _e2eeWorker: Worker | null = null; let _e2eeWorker: Worker | null = null;
let _room: Room | 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", state: "closed",
view: "preview", view: "preview",
invitedUserId: null, invitedUserId: null,

View file

@ -34,35 +34,25 @@ function getColumnCount(width: number, itemCount: number) {
type KlipyKind = "gif" | "meme"; type KlipyKind = "gif" | "meme";
type KlipyMediaFile = {
url?: string;
width?: number;
height?: number;
};
type KlipyMediaFormats = Record<string, KlipyMediaFile | undefined>;
type KlipyItem = { type KlipyItem = {
id: number | string; id: number | string;
title?: string; title?: string;
file?: Record<string, KlipyMediaFormats | undefined>; file?: Record<
string,
| Record<
string,
| {
url?: string;
width?: number;
height?: number;
}
| undefined
>
| undefined
>;
blur_preview?: string; blur_preview?: string;
}; };
type KlipyPage = {
items: KlipyItem[];
currentPage: number;
hasNext: boolean;
};
type KlipyResponse = {
data?: {
data?: KlipyItem[];
current_page?: number;
has_next?: boolean;
};
};
type PickerMedia = { type PickerMedia = {
key: React.Key; key: React.Key;
url: string; url: string;
@ -105,7 +95,11 @@ async function fetchKlipyPage({
kind: KlipyKind; kind: KlipyKind;
page: number; page: number;
search: string; search: string;
}): Promise<KlipyPage> { }): Promise<{
items: KlipyItem[];
currentPage: number;
hasNext: boolean;
}> {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(page), page: String(page),
per_page: String(pageSize), per_page: String(pageSize),
@ -128,7 +122,13 @@ async function fetchKlipyPage({
throw new Error(`Klipy request failed with status ${response.status}`); 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; const data = body.data;
return { return {

View file

@ -66,6 +66,13 @@ export default function InputComponent({
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [userId]); }, [userId]);
useEffect(() => {
if (replyTo === undefined) return;
const frame = requestAnimationFrame(() => composerRef.current?.focus());
return () => cancelAnimationFrame(frame);
}, [replyTo]);
useEffect(() => { useEffect(() => {
const focusComposerOnType = (event: KeyboardEvent) => { const focusComposerOnType = (event: KeyboardEvent) => {
const composer = composerRef.current; const composer = composerRef.current;

View file

@ -145,8 +145,6 @@ type SendMessageGet = (
data: { SendTime: number }, data: { SendTime: number },
) => Promise<{ data: RawMessage }>; ) => Promise<{ data: RawMessage }>;
type GetChatSecret = (userId: number) => Promise<Uint8Array | null>;
type StoredDraftState = ChatDraft & { type StoredDraftState = ChatDraft & {
accountId: number; accountId: number;
userId: number; userId: number;
@ -204,7 +202,7 @@ export async function fetchReplyMessage({
ownId: number; ownId: number;
chatUserId: number; chatUserId: number;
send: SendMessageGet; send: SendMessageGet;
getChatSecret: GetChatSecret; getChatSecret: (userId: number) => Promise<Uint8Array | null>;
}) { }) {
const message = await getMessage({ const message = await getMessage({
sendTime: replyTo, sendTime: replyTo,

View file

@ -21,12 +21,6 @@ import {
} from "./values"; } from "./values";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/user/wrapper";
type MessageChunk = {
key: string;
messages: Array<RawMessage | LiveMessage>;
startIndex: number;
};
function shouldFetchPreviousPage({ function shouldFetchPreviousPage({
entry, entry,
hasNextPage, hasNextPage,
@ -108,7 +102,11 @@ function buildMessageChunks(
keyPrefix: string, keyPrefix: string,
startOffset = 0, 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) { for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) {
const start = Math.max(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, getItemKey,
estimateSize, estimateSize,
overscan: 2, overscan: 2,
paddingStart: composerHeight + 8, paddingStart: composerHeight + 20,
}); });
const totalSize = virtualizer.getTotalSize(); const totalSize = virtualizer.getTotalSize();
const contentHeight = Math.max(totalSize, viewportHeight); const contentHeight = Math.max(totalSize, viewportHeight);

View file

@ -1,7 +1,5 @@
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json"; import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
type ShortcodeValue = string | string[];
export type EmojiDefinition = { export type EmojiDefinition = {
aliases: readonly string[]; aliases: readonly string[];
hexcode: string; hexcode: string;
@ -17,7 +15,7 @@ function normalizeName(value: string) {
} }
export const emojis: readonly EmojiDefinition[] = Object.entries( export const emojis: readonly EmojiDefinition[] = Object.entries(
shortcodeData as Record<string, ShortcodeValue>, shortcodeData as Record<string, string | string[]>,
).map(([hexcode, value]) => { ).map(([hexcode, value]) => {
const aliases = Array.isArray(value) ? value : [value]; const aliases = Array.isArray(value) ? value : [value];
const name = aliases[0]; const name = aliases[0];

View file

@ -76,10 +76,6 @@ export type InputProps = {
onControllerChange?: (controller: InputController | null) => void; onControllerChange?: (controller: InputController | null) => void;
}; };
type InputStyle = CSSProperties & {
"--tm-md-content-padding"?: string;
};
function toCssLength(value: CSSProperties["padding"]): string | undefined { function toCssLength(value: CSSProperties["padding"]): string | undefined {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
@ -99,11 +95,6 @@ function toCssPadding(
return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`; return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`;
} }
type TokenRange = {
from: number;
to: number;
};
const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" }); const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" });
const strongDecoration = Decoration.mark({ class: "tm-md-strong" }); const strongDecoration = Decoration.mark({ class: "tm-md-strong" });
const emDecoration = Decoration.mark({ class: "tm-md-em" }); const emDecoration = Decoration.mark({ class: "tm-md-em" });
@ -443,7 +434,9 @@ export default function Input(props: InputProps) {
props.paddingX, props.paddingX,
Boolean(props.styled), Boolean(props.styled),
), ),
} as InputStyle } as CSSProperties & {
"--tm-md-content-padding"?: string;
}
} }
/> />
); );
@ -834,7 +827,10 @@ function buildDecorations(view: EditorView): DecorationSet {
function addHiddenToken( function addHiddenToken(
builder: Range<Decoration>[], builder: Range<Decoration>[],
selections: ReadonlyArray<{ from: number; to: number }>, selections: ReadonlyArray<{ from: number; to: number }>,
token: TokenRange, token: {
from: number;
to: number;
},
): void { ): void {
if (token.from >= token.to) return; if (token.from >= token.to) return;

View file

@ -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 Emoji from "./emoji";
import { findEmojiShortcodes } from "./emojiData"; import { findEmojiShortcodes } from "./emojiData";
@ -23,43 +30,11 @@ type InlineTokenRange = {
to: number; 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 = { type ListItem = {
text: string; text: string;
checked: boolean | null; checked: boolean | null;
}; };
type ListBlock = {
type: "list";
ordered: boolean;
items: ListItem[];
};
type TableBlock = { type TableBlock = {
type: "table"; type: "table";
headers: string[]; headers: string[];
@ -67,17 +42,140 @@ type TableBlock = {
}; };
type MarkdownBlock = type MarkdownBlock =
| ParagraphBlock | {
| HeadingBlock type: "paragraph";
| HrBlock text: string;
| BlockQuoteBlock }
| CodeBlock | {
| ListBlock 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; | TableBlock;
const INLINE_TOKEN_REGEX = const INLINE_TOKEN_REGEX =
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g; /!\[([^\]]*)\]\(([^)\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. * Executes parseInlineNodes.
* @param input Parameter input. * @param input Parameter input.
@ -429,11 +527,7 @@ function renderInline(nodes: InlineNode[]): ReactNode[] {
} }
if (node.type === "code") { if (node.type === "code") {
return ( return <CopyableCode key={index} value={node.value} />;
<code key={index} className="tm-md-code">
{node.value}
</code>
);
} }
if (node.type === "link") { if (node.type === "link") {
@ -523,11 +617,12 @@ export function renderBlocks(blocks: MarkdownBlock[]): ReactElement {
if (block.type === "code") { if (block.type === "code") {
return ( return (
<pre key={blockIndex} className="tm-md-pre"> <CopyableCode
<code className="tm-md-codeblock" data-language={block.language}> key={blockIndex}
{block.code} block
</code> language={block.language}
</pre> value={block.code}
/>
); );
} }
@ -666,7 +761,7 @@ function readTable(
} }
const markdownStyles = ` 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-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
.tm-md-h1 { font-size: 1.65rem; } .tm-md-h1 { font-size: 1.65rem; }
.tm-md-h2 { font-size: 1.45rem; } .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-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 { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; }
.tm-md-blockquote p { margin: 0.2rem 0; } .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-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; } .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-radius: 0.28rem; background: hsl(var(--muted)); } .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-strong { font-weight: 700; }
.tm-md-em { font-style: italic; } .tm-md-em { font-style: italic; }
.tm-md-del { text-decoration: line-through; } .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-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-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; } .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-wrap { overflow-x: auto; margin: 0.45rem 0; }
.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; } .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, .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; } .tm-md-hr { margin: 0.55rem 0; }
.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); } .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-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 { 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-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-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-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-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-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; } .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; if (typeof document === "undefined") return;
const styleId = "tensamin-markdown-styles"; const styleId = "tensamin-markdown-styles";
if (document.getElementById(styleId)) return; let style = document.getElementById(styleId) as HTMLStyleElement | null;
const style = document.createElement("style"); if (!style) {
style.id = styleId; style = document.createElement("style");
style.textContent = markdownStyles; style.id = styleId;
document.head.appendChild(style); document.head.appendChild(style);
}
if (style.textContent !== markdownStyles) {
style.textContent = markdownStyles;
}
} }

View file

@ -631,16 +631,6 @@ type NativeSnapshot = {
error?: string; 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: { function TauriProvider(props: {
children: ReactNode; children: ReactNode;
blockConnection?: boolean; blockConnection?: boolean;
@ -713,7 +703,16 @@ function TauriProvider(props: {
let disposed = false; let disposed = false;
let unlisten: UnlistenFn | undefined; let unlisten: UnlistenFn | undefined;
void (async () => { 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 (disposed) return;
if (payload.kind === "state") { if (payload.kind === "state") {
applySnapshot(payload.snapshot); applySnapshot(payload.snapshot);

View file

@ -5,7 +5,7 @@ import { useMTP } from "@tensamin/mtp";
import { createContext, useEffect, useContext } from "react"; import { createContext, useEffect, useContext } from "react";
import { toast as sonnerToast } from "sonner"; import { toast as sonnerToast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@methanium/ui"; import { Avatar, AvatarFallback, AvatarImage } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core"; import { invoke, isTauri } from "@tauri-apps/api/core";
import { import {
isPermissionGranted as isTauriNotificationPermissionGranted, isPermissionGranted as isTauriNotificationPermissionGranted,
requestPermission as requestTauriNotificationPermission, requestPermission as requestTauriNotificationPermission,
@ -108,7 +108,30 @@ export default function Provider(props: { children: React.ReactNode }) {
(await requestTauriNotificationPermission()) === "granted"; (await requestTauriNotificationPermission()) === "granted";
if (permissionGranted) { 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 { } else {
const hasPermissions = await requestNotificationPermission(); const hasPermissions = await requestNotificationPermission();

View file

@ -21,10 +21,10 @@ export {
type OnboardingStepControls, type OnboardingStepControls,
} from "@methanium/ui"; } from "@methanium/ui";
type LegalDocs = z.infer<typeof legalDocsSchema>;
interface GateState { interface GateState {
docs: LegalDocs; docs: z.infer<typeof legalDocsSchema>;
acceptedPP: boolean; acceptedPP: boolean;
acceptedTOS: boolean; acceptedTOS: boolean;
includeLegal: boolean; includeLegal: boolean;

View file

@ -5,7 +5,7 @@ import type { z } from "zod";
import { useOnboardingStep } from "@methanium/ui"; import { useOnboardingStep } from "@methanium/ui";
type LegalDocs = z.infer<typeof legalDocsSchema>;
export default function LegalPage({ export default function LegalPage({
docs, docs,
@ -13,7 +13,7 @@ export default function LegalPage({
initiallyAcceptedTOS, initiallyAcceptedTOS,
onAccept, onAccept,
}: { }: {
docs: LegalDocs; docs: z.infer<typeof legalDocsSchema>;
initiallyAcceptedPP: boolean; initiallyAcceptedPP: boolean;
initiallyAcceptedTOS: boolean; initiallyAcceptedTOS: boolean;
onAccept: () => Promise<void>; onAccept: () => Promise<void>;

View file

@ -5,9 +5,7 @@ import { storageDefaults, type Storage } from "@tensamin/shared/data";
import { settingsStorageDefaults } from "@tensamin/shared/settings"; import { settingsStorageDefaults } from "@tensamin/shared/settings";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
type BooleanStorageKey = {
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
}[keyof Storage];
type ListStorageKey = { type ListStorageKey = {
[K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never; [K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never;
@ -21,7 +19,9 @@ export function Switch({
id, id,
}: { }: {
label: React.ReactNode; 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 { save, load } = useStorage();
const [value, setValue] = useState<boolean>(settingsStorageDefaults[id]); const [value, setValue] = useState<boolean>(settingsStorageDefaults[id]);

View file

@ -143,25 +143,7 @@ export type Contacts = z.infer<typeof authPayload.shape.Contacts>;
export type Communities = z.infer<typeof authPayload.shape.Communities>; export type Communities = z.infer<typeof authPayload.shape.Communities>;
export type Calls = z.infer<typeof authPayload.shape.Calls>; 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 // MTP
const user = z.object({ const user = z.object({
@ -472,7 +454,25 @@ export interface Storage extends SettingsStorageDefaults {
call_mute_range_start: number; call_mute_range_start: number;
call_mute_range_end: number; call_mute_range_end: number;
theme_color: string; 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_primary_color: string;
theme_polarity: "dark" | "light" | "system"; theme_polarity: "dark" | "light" | "system";
theme_tint: "soft" | "hard" | "extreme"; theme_tint: "soft" | "hard" | "extreme";

View file

@ -22,7 +22,11 @@ export type DesktopScreenShareCapabilities = {
hasReliableSystemAudio: boolean; hasReliableSystemAudio: boolean;
}; };
type ElectronDesktopApi = {
declare global {
interface Window {
tensaminDesktop?: {
media?: { media?: {
getScreenShareCapabilities?: () => Promise<DesktopScreenShareCapabilities>; getScreenShareCapabilities?: () => Promise<DesktopScreenShareCapabilities>;
listScreenShareSources?: () => Promise<DesktopScreenShareSource[]>; listScreenShareSources?: () => Promise<DesktopScreenShareSource[]>;
@ -53,10 +57,6 @@ type ElectronDesktopApi = {
clear?: () => Promise<void>; clear?: () => Promise<void>;
}; };
}; };
declare global {
interface Window {
tensaminDesktop?: ElectronDesktopApi;
} }
} }

View file

@ -1,14 +1,14 @@
type StringKeyOf<T> = Extract<keyof T, string>; type StringKeyOf<T> = Extract<keyof T, string>;
type SettingDefinition = {
display: string;
type: string;
default?: unknown;
};
export type SettingsSchema = Record< export type SettingsSchema = Record<
string, string,
Record<string, Record<string, SettingDefinition>> Record<string, Record<string, {
display: string;
type: string;
default?: unknown;
}>>
>; >;
const settings = { const settings = {

View file

@ -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),
];
},
});
},
};
},
};

View file

@ -36,7 +36,7 @@ for (const targetDir of targetDirs) {
const entry = relative(rootDir, fullPath); const entry = relative(rootDir, fullPath);
console.log(`Linting ${entry}...`); console.log(`Linting ${entry}...`);
try { try {
execSync("pnpm run lint", { cwd: fullPath, stdio: "inherit" }); execSync("pnpm run lint --fix", { cwd: fullPath, stdio: "inherit" });
console.log(`${entry} linted successfully.`); console.log(`${entry} linted successfully.`);
} catch { } catch {
console.error(`Failed to lint ${entry}.`); console.error(`Failed to lint ${entry}.`);