dev #27

Merged
alois merged 13 commits from dev into main 2026-08-05 22:46:28 +03:00
73 changed files with 8659 additions and 6399 deletions

1
TODO
View file

@ -1,5 +1,4 @@
- Add a bunch of tests
- Add packages/hotkeys/
- Full accessability
- Add settings saving & update onboarding to use it
- Add onboarding page to load one profile during onboarding

Binary file not shown.

View file

@ -5,6 +5,7 @@ import {
app,
BrowserWindow,
desktopCapturer,
globalShortcut,
ipcMain,
session,
shell,
@ -13,6 +14,7 @@ import { checkForUpdates } from "./updates.js";
import {
ipcChannels,
type DesktopCallStatus,
type DesktopGlobalHotkeyBinding,
type DesktopScreenShareAudioOutput,
type DesktopScreenShareCapabilities,
} from "../shared/ipc.js";
@ -29,6 +31,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const verbose = process.argv.includes("--verbose");
let mainWindow: BrowserWindow | null = null;
let selectedScreenShareSourceId: string | null = null;
let globalHotkeyBindings: DesktopGlobalHotkeyBinding[] = [];
let globalHotkeysSuspended = false;
app.setName("tensamin");
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
@ -46,6 +50,13 @@ if (
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
}
if (
process.platform === "linux" &&
process.env.XDG_SESSION_TYPE === "wayland"
) {
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
}
if (
process.platform === "linux" &&
process.env.XDG_SESSION_TYPE === "wayland" &&
@ -242,6 +253,24 @@ function registerIpc() {
deleteSecureStorage(key),
);
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
ipcMain.handle(
ipcChannels.setGlobalHotkeyBindings,
(event, bindings: unknown) => {
assertTrustedRenderer(event);
return setGlobalHotkeyBindings(bindings);
},
);
ipcMain.handle(
ipcChannels.setGlobalHotkeysSuspended,
(event, suspended: unknown) => {
assertTrustedRenderer(event);
if (typeof suspended !== "boolean") {
throw new Error("Invalid hotkey suspension state.");
}
globalHotkeysSuspended = suspended;
return applyGlobalHotkeyBindings();
},
);
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
if (
typeof status !== "object" ||
@ -282,6 +311,90 @@ function registerIpc() {
});
}
function assertTrustedRenderer(event: Electron.IpcMainInvokeEvent) {
const target = mainWindow;
if (
!target ||
target.isDestroyed() ||
event.sender !== target.webContents ||
event.senderFrame !== target.webContents.mainFrame
) {
throw new Error("Untrusted hotkey IPC sender.");
}
try {
if (fileURLToPath(event.senderFrame.url) === getRendererIndex()) return;
} catch {
// Fall through to the rejection below.
}
throw new Error("Untrusted hotkey IPC sender.");
}
function validGlobalHotkeyBindings(
value: unknown,
): value is DesktopGlobalHotkeyBinding[] {
return (
Array.isArray(value) &&
value.length <= 64 &&
value.every(
(binding) =>
binding &&
typeof binding === "object" &&
typeof (binding as DesktopGlobalHotkeyBinding).id === "string" &&
/^[a-z0-9.-]+$/i.test((binding as DesktopGlobalHotkeyBinding).id) &&
(binding as DesktopGlobalHotkeyBinding).id.length > 0 &&
(binding as DesktopGlobalHotkeyBinding).id.length <= 128 &&
typeof (binding as DesktopGlobalHotkeyBinding).accelerator ===
"string" &&
(binding as DesktopGlobalHotkeyBinding).accelerator.length > 0 &&
(binding as DesktopGlobalHotkeyBinding).accelerator.length <= 128,
)
);
}
function applyGlobalHotkeyBindings() {
globalShortcut.unregisterAll();
const statuses = Object.fromEntries(
globalHotkeyBindings.map(({ id }) => [id, false]),
);
if (globalHotkeysSuspended) return statuses;
const grouped = new Map<string, string[]>();
for (const { id, accelerator } of globalHotkeyBindings) {
const ids = grouped.get(accelerator) ?? [];
ids.push(id);
grouped.set(accelerator, ids);
}
for (const [accelerator, ids] of grouped) {
let registered = false;
try {
registered = globalShortcut.register(accelerator, () => {
const target = mainWindow;
if (!target || target.isDestroyed()) return;
ids.forEach((id) =>
target.webContents.send(ipcChannels.globalHotkeyTriggered, id),
);
});
} catch (error) {
console.error("Failed to register global hotkey", accelerator, error);
}
ids.forEach((id) => {
statuses[id] = registered;
});
}
return statuses;
}
function setGlobalHotkeyBindings(bindings: unknown) {
if (!validGlobalHotkeyBindings(bindings)) {
throw new Error("Invalid global hotkey bindings.");
}
globalHotkeyBindings = bindings;
return applyGlobalHotkeyBindings();
}
async function createWindow() {
const rendererIndex = getRendererIndex();
verboseLog("creating main window", {
@ -367,6 +480,10 @@ app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
});
app.on("will-quit", () => {
globalShortcut.unregisterAll();
});
if (verbose) {
process.on("uncaughtException", (error) => {
console.error("[tensamin:electron] uncaught exception", error);

View file

@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
import {
ipcChannels,
type DesktopCallStatus,
type DesktopGlobalHotkeyBinding,
type DesktopScreenShareSource,
secureStorageLimits,
} from "../shared/ipc.js";
@ -55,6 +56,23 @@ const desktopApi = {
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
},
},
hotkeys: {
setBindings: (bindings: DesktopGlobalHotkeyBinding[]) =>
ipcRenderer.invoke(ipcChannels.setGlobalHotkeyBindings, bindings),
setSuspended: (suspended: boolean) =>
typeof suspended === "boolean"
? ipcRenderer.invoke(ipcChannels.setGlobalHotkeysSuspended, suspended)
: Promise.reject(new Error("Invalid hotkey suspension state.")),
onTriggered: (callback: (id: string) => void) => {
const listener = (_event: Electron.IpcRendererEvent, id: unknown) => {
if (typeof id === "string") callback(id);
};
ipcRenderer.on(ipcChannels.globalHotkeyTriggered, listener);
return () => {
ipcRenderer.removeListener(ipcChannels.globalHotkeyTriggered, listener);
};
},
},
secureStorage: {
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
load: (key: string) =>

View file

@ -31,6 +31,11 @@ export type DesktopSecureStorageStatus = {
backend: string | null;
};
export type DesktopGlobalHotkeyBinding = {
id: string;
accelerator: string;
};
export const secureStorageLimits = {
maxKeyBytes: 256,
maxValueBytes: 1024 * 1024,
@ -77,4 +82,7 @@ export const ipcChannels = {
saveSecureStorage: "secureStorage:save",
deleteSecureStorage: "secureStorage:delete",
clearSecureStorage: "secureStorage:clear",
setGlobalHotkeyBindings: "hotkeys:setBindings",
setGlobalHotkeysSuspended: "hotkeys:setSuspended",
globalHotkeyTriggered: "hotkeys:triggered",
} as const;

View file

@ -0,0 +1,2 @@
[env]
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,53 @@
import { spawnSync } from "node:child_process";
import { createInterface } from "node:readline/promises";
const devicesResult = spawnSync("adb", ["devices", "-l"], {
encoding: "utf8",
});
if (devicesResult.status !== 0) {
process.stderr.write(devicesResult.stderr);
process.exit(devicesResult.status ?? 1);
}
const devices = devicesResult.stdout
.split("\n")
.slice(1)
.map((line) => line.trim())
.filter((line) => /\sdevice(?:\s|$)/.test(line));
if (devices.length === 0) {
console.error("No connected ADB devices found.");
process.exit(1);
}
let selectedDevice = devices[0];
if (devices.length > 1) {
console.log("Select a device:");
devices.forEach((device, index) => console.log(`${index + 1}) ${device}`));
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await readline.question("Device: ");
readline.close();
const selectedIndex = Number(answer) - 1;
if (!Number.isInteger(selectedIndex) || !devices[selectedIndex]) {
console.error("Invalid device selection.");
process.exit(1);
}
selectedDevice = devices[selectedIndex];
}
const serial = selectedDevice.split(/\s+/, 1)[0];
const uninstallResult = spawnSync(
"adb",
["-s", serial, "uninstall", "net.tensamin.client.dev"],
{ stdio: "inherit" },
);
process.exit(uninstallResult.status ?? 1);

File diff suppressed because it is too large Load diff

View file

@ -15,12 +15,18 @@ name = "mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef", features = [] }
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "20f3f2515c65ca1e115991c3ef3625e2c29c1bbb", features = [] }
[dependencies]
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "d10266198d62ca9e14a8b1a55d2ab108b24e756e", features = ["client", "crypto"] }
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "d10266198d62ca9e14a8b1a55d2ab108b24e756e" }
webpki-root-certs = "1"
tauri-plugin-deep-link = "2"
tauri-plugin-notification = "2"
tauri-plugin-log = "2"
@ -30,10 +36,12 @@ version = "2"
features = []
default-features = true
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2"
tauri-plugin-app-events = "0.2"
[patch.crates-io.tauri]
git = "https://github.com/tauri-apps/tauri"
branch = "feat/cef"
rev = "20f3f2515c65ca1e115991c3ef3625e2c29c1bbb"

View file

@ -9,7 +9,6 @@
],
"permissions": [
"deep-link:default",
"app-events:default",
"barcode-scanner:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",

View file

@ -6,7 +6,10 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
@ -50,6 +53,25 @@
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<service
android:name=".MtpForegroundService"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the user-enabled encrypted messaging connection and receives incoming messages" />
</service>
<receiver
android:name=".MtpBootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View file

@ -56,7 +56,7 @@ class MainActivity : TauriActivity() {
}
override fun onWebViewCreate(webView: WebView) {
webView.setInitialScale(300)
webView.setInitialScale(290)
mediaWebView = webView
MobileMediaEvents.attach(webView)
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
@ -65,10 +65,24 @@ class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, true)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
NativeMtpBridge.startService(this)
}
super.onCreate(savedInstanceState)
NativeMtpBridge.nativeAttach(applicationContext)
installKeyboardResizeWorkaround()
}
override fun onResume() {
super.onResume()
NativeMtpBridge.nativeSetUiState(true)
}
override fun onPause() {
NativeMtpBridge.nativeSetUiState(false)
super.onPause()
}
override fun onDestroy() {
attachLayoutListener?.let { listener ->
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)

View file

@ -0,0 +1,17 @@
package net.tensamin.client
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class MtpBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (
intent.action == Intent.ACTION_BOOT_COMPLETED &&
MtpSecureStore.isEnabled(context) &&
MtpSecureStore.hasConfig(context)
) {
NativeMtpBridge.startService(context)
}
}
}

View file

@ -0,0 +1,120 @@
package net.tensamin.client
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
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 {
if (intent?.action == ACTION_STOP) {
MtpSecureStore.setEnabled(this, false)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
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) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
val config = MtpSecureStore.loadConfig(this)
if (config == null || !MtpSecureStore.isEnabled(this)) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
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)
updateNotification(this, "Connection failed")
}
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()
}
companion object {
private const val CHANNEL_ID = "tensamin-connection"
private const val NOTIFICATION_ID = 2201
private const val ACTION_STOP = "net.tensamin.client.STOP_MTP"
fun updateNotification(context: Context, status: String) {
if (!MtpSecureStore.isEnabled(context)) return
createChannel(context)
context.getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification(context, status))
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Background connection",
NotificationManager.IMPORTANCE_LOW,
).apply { description = "Keeps Tensamin connected for incoming messages" },
)
}
private fun buildNotification(context: Context, status: String): Notification {
val openIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = PendingIntent.getService(
context,
1,
Intent(context, MtpForegroundService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Tensamin")
.setContentText(status)
.setContentIntent(openIntent)
.setOngoing(true)
.setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent)
.build()
}
}
}

View file

@ -0,0 +1,74 @@
package net.tensamin.client
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
object MtpSecureStore {
private const val KEY_ALIAS = "tensamin-mtp-config"
private const val PREFS = "tensamin-mtp"
private const val CONFIG = "config"
private const val ENABLED = "enabled"
fun saveConfig(context: Context, config: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val encrypted = cipher.doFinal(config.toByteArray(Charsets.UTF_8))
val payload = Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP)
preferences(context).edit().putString(CONFIG, payload).apply()
}
fun loadConfig(context: Context): String? {
val payload = preferences(context).getString(CONFIG, null) ?: return null
return runCatching {
val bytes = Base64.decode(payload, Base64.NO_WRAP)
require(bytes.size > 12)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
getOrCreateKey(),
GCMParameterSpec(128, bytes.copyOfRange(0, 12)),
)
String(cipher.doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)
}.getOrNull()
}
fun hasConfig(context: Context): Boolean = loadConfig(context) != null
fun setEnabled(context: Context, enabled: Boolean) {
preferences(context).edit().putBoolean(ENABLED, enabled).apply()
}
fun isEnabled(context: Context): Boolean =
preferences(context).getBoolean(ENABLED, false)
private fun preferences(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore",
)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return generator.generateKey()
}
}

View file

@ -0,0 +1,161 @@
package net.tensamin.client
import android.app.Notification
import android.app.NotificationChannel
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
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 {
private const val MESSAGE_CHANNEL = "tensamin-messages"
init {
System.loadLibrary("mobile_lib")
}
@JvmStatic external fun nativeAttach(context: Context)
@JvmStatic external fun nativeStart(config: String)
@JvmStatic external fun nativeStop()
@JvmStatic external fun nativeSetUiState(visible: Boolean)
@JvmStatic external fun nativeLog(level: Int, message: String, details: String)
fun log(level: Int, message: String, error: Throwable? = null) {
val details = error?.stackTraceToString().orEmpty()
Log.println(if (level == 0) Log.ERROR else Log.INFO, "TensaminAndroid", "$message $details")
nativeLog(level, message, details)
}
fun storeConfig(context: Context, config: String) {
try {
MtpSecureStore.saveConfig(context, config)
if (MtpSecureStore.isEnabled(context)) startService(context)
log(2, "Stored native MTP credentials")
} catch (error: Throwable) {
log(0, "Failed to store native MTP credentials", error)
throw error
}
}
fun hasConfig(context: Context): Boolean = MtpSecureStore.hasConfig(context)
fun setServiceEnabled(context: Context, enabled: Boolean) {
MtpSecureStore.setEnabled(context, enabled)
if (enabled && MtpSecureStore.hasConfig(context)) startService(context) else stopService(context)
}
fun isIgnoringBatteryOptimizations(context: Context): Boolean =
context.getSystemService(PowerManager::class.java)
.isIgnoringBatteryOptimizations(context.packageName)
fun requestBatteryExemption(context: Context) {
val intent = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${context.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
fun startService(context: Context) {
ContextCompat.startForegroundService(
context,
Intent(context, MtpForegroundService::class.java),
)
}
fun stopService(context: Context) {
if (!context.stopService(Intent(context, MtpForegroundService::class.java))) nativeStop()
}
fun updateServiceStatus(context: Context, status: String) {
MtpForegroundService.updateNotification(context, status)
}
fun postMessageNotification(
context: Context,
senderId: Long,
sender: String,
body: String,
avatar: ByteArray,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
MESSAGE_CHANNEL,
"Messages",
NotificationManager.IMPORTANCE_HIGH,
).apply { description = "Incoming Tensamin messages" },
)
}
val openIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse("tensamin://chat?id=$senderId"),
context,
MainActivity::class.java,
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(
context,
senderId.hashCode(),
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val avatarBitmap = avatar.takeIf { it.isNotEmpty() }?.let {
BitmapFactory.decodeByteArray(it, 0, it.size)
}
val avatarIcon = avatarBitmap?.let(IconCompat::createWithAdaptiveBitmap)
val person = Person.Builder()
.setName(sender)
.setKey(senderId.toString())
.setIcon(avatarIcon)
.build()
val shortcutId = "chat-$senderId"
val shortcut = ShortcutInfoCompat.Builder(context, shortcutId)
.setShortLabel(sender)
.setLongLived(true)
.setPerson(person)
.setIntent(openIntent)
.apply { if (avatarIcon != null) setIcon(avatarIcon) }
.build()
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
val style = NotificationCompat.MessagingStyle(
Person.Builder().setName("You").build(),
).addMessage(body, System.currentTimeMillis(), person)
val notification = NotificationCompat.Builder(context, MESSAGE_CHANNEL)
.setSmallIcon(R.drawable.ic_notification_small)
.setContentTitle(sender)
.setContentText(body)
.setStyle(style)
.setShortcutId(shortcutId)
.setLocusId(LocusIdCompat(shortcutId))
.setLargeIcon(avatarBitmap)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
context.getSystemService(NotificationManager::class.java)
.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

@ -1,7 +1,13 @@
mod mtp_backend;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build())
.plugin(
tauri_plugin_log::Builder::new()
.level(tauri_plugin_log::log::LevelFilter::Info)
.build(),
)
.plugin(tauri_plugin_notification::init());
let builder = builder
@ -11,11 +17,21 @@ pub fn run() {
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_app_events::init());
if let Err(error) = builder
let app = builder
.invoke_handler(tauri::generate_handler![
mtp_backend::mtp_request,
mtp_backend::mtp_status,
mtp_backend::mtp_store_credentials,
mtp_backend::mtp_has_credentials,
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,
])
.setup(|_app| {
mtp_backend::manager().attach_app(_app.handle().clone());
#[cfg(any(target_os = "linux", windows))]
{
use tauri_plugin_deep_link::DeepLinkExt;
@ -30,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();
}
}
});
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -53,6 +53,7 @@
"@tensamin/cache": "workspace:*",
"@tensamin/chat": "workspace:*",
"@tensamin/crypto": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/settings": "workspace:*",
"@tensamin/storage": "workspace:*",

View file

@ -13,7 +13,7 @@ import {
useState,
} from "react";
import { z } from "zod";
import { isTauri } from "@tauri-apps/api/core";
import { invoke, isTauri } from "@tauri-apps/api/core";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
import { useNavigate } from "@tanstack/react-router";
@ -64,7 +64,8 @@ function parseTuFileContent(rawFileContent: string): {
throw new Error("Invalid file");
}
const [userIdString, privateKey] = rawFileContent.split("::");
const [userIdString, privateKeyValue] = rawFileContent.split("::");
const privateKey = privateKeyValue.trim();
const userId = isNaN(Number(userIdString))
? Number(userIdString.split("@")[0])
: Number(userIdString);
@ -87,7 +88,7 @@ export default function Form() {
const isMobile = useIsMobile();
const uploadRef = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const { save } = useStorage();
const { load, save } = useStorage();
const navigate = useNavigate();
const loginPendingRef = useRef(false);
@ -97,6 +98,23 @@ export default function Form() {
loginPendingRef.current = true;
try {
if (domain) await save("omega_url", `https://${domain}/`);
if (isTauri()) {
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] =
await Promise.all([
domain ? `https://${domain}/` : load("omega_url"),
load("forced_omikron_url"),
load("forced_omikron_public_key"),
]);
await invoke("mtp_store_credentials", {
config: {
userId,
keyring: privateKey,
omegaUrl,
forcedOmikronUrl,
forcedOmikronPublicKey,
},
});
}
await save("mtp_keyring", privateKey, { secure: true });
await save("session_id", Date.now());
await save("user_id", userId);
@ -106,7 +124,7 @@ export default function Form() {
loginPendingRef.current = false;
}
},
[navigate, save],
[load, navigate, save],
);
// Process dropped files

View file

@ -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";
@ -49,6 +49,7 @@ import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core";
import { HotkeysProvider } from "@tensamin/hotkeys";
const wrapper = document.getElementById("root");
@ -266,10 +267,12 @@ function RootShell() {
/>
<TooltipProvider>
<Storage>
<ThemeStorageBridge />
<LoginWrapper>
<Outlet />
</LoginWrapper>
<HotkeysProvider>
<ThemeStorageBridge />
<LoginWrapper>
<Outlet />
</LoginWrapper>
</HotkeysProvider>
</Storage>
</TooltipProvider>
</div>
@ -285,6 +288,7 @@ function AppShell() {
<DesktopMediaProvider>
<MTPProvider>
<CacheSync />
<DeeplinkNavigator />
<Session>
<UserProvider>
<CallInit />
@ -306,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;

View file

@ -82,9 +82,7 @@ export default defineConfig({
"use-sync-external-store",
"@tanstack/history",
"@tanstack/react-router",
"@tanstack/react-store",
"@tanstack/router-core",
"@tanstack/store",
"@tensamin/crypto",
"@tensamin/settings",
"@tensamin/storage",
@ -134,6 +132,7 @@ export default defineConfig({
"@tensamin/chat",
"@tensamin/crypto",
"@tensamin/crypto/context",
"@tensamin/hotkeys",
"@tensamin/markdown",
"@tensamin/mtp",
"@tensamin/notifications",

View file

@ -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",
},

View file

@ -376,6 +376,8 @@
printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties"
fi
fi
adb devices
'';
};
}

View file

@ -1,6 +1,6 @@
{
"name": "tensamin",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"packageManager": "pnpm@11.8.0",
"workspaces": [
@ -21,12 +21,12 @@
"dev": "cd apps/web && pnpm dev",
"build:web": "cd apps/web && pnpm run build",
"preview:web": "cd apps/web && pnpm run preview",
"dev:mobile": "cd apps/tauri && pnpm dev:mobile",
"dev:mobile": "pnpm run delete:mobile || true && cd apps/tauri && pnpm dev:mobile && pnpm run delete:mobile",
"build:mobile": "cd apps/tauri && pnpm run build:mobile",
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
"dev:desktop": "cd apps/electron && pnpm run dev",
"build:desktop": "cd apps/electron && pnpm run package",
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client.dev"
"delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

View file

@ -13,9 +13,11 @@ import {
} from "./helpers";
import {
accountIdSchema,
chatDraftSchema,
contactsSchema,
conversationWindowSchema,
userProfileSchema,
type ChatDraft,
type Contact,
type ConversationWindow,
type UserProfile,
@ -24,7 +26,7 @@ import {
export * from "./helpers";
export * from "./schemas";
type CacheStore = "contacts" | "profiles" | "conversations";
type CacheStore = "contacts" | "profiles" | "conversations" | "drafts";
export interface SecureValueCodec {
encode(value: unknown): unknown | Promise<unknown>;
@ -237,19 +239,25 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
},
delete: (userId: number) => remove("conversations", String(userId)),
},
drafts: {
get: (userId: number) => read("drafts", String(userId), chatDraftSchema),
put: (userId: number, draft: ChatDraft) =>
write("drafts", String(userId), chatDraftSchema, draft),
delete: (userId: number) => remove("drafts", String(userId)),
},
clearAccount: async () => {
ensureOpen();
await Promise.all(
(["contacts", "profiles", "conversations"] as CacheStore[]).map(
async (store) => {
const storedEntries = await entries(store);
await Promise.all(
storedEntries.map(([key]) =>
deleteDatabaseEntry("cache", storedKey(store, key)),
),
);
},
),
(
["contacts", "profiles", "conversations", "drafts"] as CacheStore[]
).map(async (store) => {
const storedEntries = await entries(store);
await Promise.all(
storedEntries.map(([key]) =>
deleteDatabaseEntry("cache", storedKey(store, key)),
),
);
}),
);
},
close: async () => {

View file

@ -20,8 +20,14 @@ export const conversationWindowSchema = z.object({
LastMessageAt: z.number(),
Messages: z.array(cachedMessageSchema),
});
// Unlike cached messages, draft content is plaintext and must use a secure codec.
export const chatDraftSchema = z.object({
Content: z.string(),
ReplyId: z.number().optional(),
});
export type Contact = z.infer<typeof contactSchema>;
export type UserProfile = z.infer<typeof userProfileSchema>;
export type CachedMessage = z.infer<typeof cachedMessageSchema>;
export type ConversationWindow = z.infer<typeof conversationWindowSchema>;
export type ChatDraft = z.infer<typeof chatDraftSchema>;

View file

@ -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

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -22,6 +22,7 @@
"@tanstack/react-router": "^1.0.0",
"@tanstack/react-virtual": "^3.0.0",
"@tensamin/crypto": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",

View file

@ -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 {

View file

@ -1,4 +1,4 @@
import Input from "@tensamin/markdown/input";
import Input, { type InputController } from "@tensamin/markdown/input";
import {
Card,
CardHeader,
@ -7,10 +7,10 @@ import {
PopoverTrigger,
} from "@methanium/ui";
import { useStorage } from "@tensamin/storage/context";
import React, { useEffect, useState, useRef } from "react";
import React, { useCallback, useEffect, useState, useRef } from "react";
import { Button } from "@methanium/ui";
import { Plus, Laugh, FileVideo } from "lucide-react";
import { Plus, Laugh, FileVideo, SendHorizonal } from "lucide-react";
import { useChat, useReplyMessage } from "../context";
import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
@ -22,13 +22,17 @@ import GifPicker from "./gifPicker";
import EmojiPicker from "./emojiPicker";
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys";
import { editLastMessageHotkey } from "../hotkeys";
export default function InputComponent({
value,
setValue,
onEditLastMessage,
}: {
value: string;
setValue: (value: string) => void;
onEditLastMessage: () => void;
}) {
const [invertEnterBehavior, setInvertEnterBehavior] = useState(false);
@ -52,6 +56,65 @@ export default function InputComponent({
width: number;
height: number;
}>();
const composerRef = useRef<InputController | null>(null);
const setComposer = useCallback((controller: InputController | null) => {
composerRef.current = controller;
}, []);
useEffect(() => {
const frame = requestAnimationFrame(() => composerRef.current?.focus());
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;
const target = event.target;
if (
!composer ||
composer.hasFocus() ||
event.defaultPrevented ||
event.isComposing ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.key.length !== 1
) {
return;
}
if (
target instanceof HTMLElement &&
(target.isContentEditable ||
target.closest(
'button, a[href], input, textarea, select, summary, [contenteditable], [role], [tabindex]:not([tabindex="-1"])',
))
) {
return;
}
event.preventDefault();
event.stopPropagation();
composer.focus();
composer.insertText(event.key);
};
window.addEventListener("keydown", focusComposerOnType, true);
return () =>
window.removeEventListener("keydown", focusComposerOnType, true);
}, []);
useHotkey(editLastMessageHotkey, onEditLastMessage, {
enabled: value.length === 0,
ignoreInputs: false,
target: inputBoxRef,
});
useEffect(() => {
void load("settings.reverse_enter_behavior").then((shouldInvert) => {
@ -195,6 +258,7 @@ export default function InputComponent({
{/* reply */}
{replyTo !== undefined && (
<ReplyBox
edited={replyMessage?.Edited}
content={replyMessage?.Content}
loading={!replyMessage}
onDismiss={() => setReplyTo(undefined)}
@ -210,18 +274,27 @@ export default function InputComponent({
)}
>
<CardHeader className="relative p-0 flex flex-col">
<Input
className="w-full"
paddingY="13px"
paddingX="13px"
placeholder="Send a message..."
value={value}
setValue={setValue}
onSubmit={handleSubmit}
invertEnterBehavior={invertEnterBehavior}
emojiFrequencies={emojiFrequencies}
onEmojiSelect={recordUse}
/>
<div className="w-full flex items-center">
<Input
className="w-full"
onControllerChange={setComposer}
paddingY="13px"
paddingX="13px"
placeholder="Send a message..."
value={value}
setValue={setValue}
onSubmit={handleSubmit}
invertEnterBehavior={invertEnterBehavior}
emojiFrequencies={emojiFrequencies}
onEmojiSelect={recordUse}
/>
<Button
onClick={() => handleSubmit(value)}
className={cn("w-9! h-9!", isMobile ? "" : "hidden")}
>
<SendHorizonal />
</Button>
</div>
<div className="w-full flex justify-between gap-1 p-1 pt-0">
<div className="flex gap-1">
<Button className="w-9 h-9! p-0" variant="ghost">

View file

@ -1,7 +1,7 @@
import type { RawMessage } from "../values";
import Text from "@tensamin/markdown/text";
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { User } from "@tensamin/user/context";
import {
@ -24,17 +24,23 @@ import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
import { useRecordEmojiUse } from "./emojiRanks";
import { useUser } from "@tensamin/user/context";
import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys";
import { cancelMessageEditHotkey } from "../hotkeys";
function MessageComponent({
editing,
grouped,
message,
onSetEditing,
user,
}: {
editing: boolean;
grouped: boolean;
message: RawMessage & {
failed?: boolean;
decryptionFailed?: boolean;
};
onSetEditing: (editing: boolean) => void;
user: User | null;
}) {
const actuallyFailed =
@ -48,6 +54,7 @@ function MessageComponent({
: message.MessageState === "awaiting"
? "opacity-50"
: "opacity-100";
const messageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const timeout = window.setTimeout(() => {
setHasFadedIn(true);
@ -141,7 +148,6 @@ function MessageComponent({
chatSecret,
editMessage,
userId,
deleteMessage,
removeReaction,
replyTo,
} = useChat();
@ -183,8 +189,27 @@ function MessageComponent({
};
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
const recordUse = useRecordEmojiUse();
const [editing, setEditing] = useState(false);
const editingRef = useRef(editing);
const onSetEditingRef = useRef(onSetEditing);
useEffect(() => {
editingRef.current = editing;
onSetEditingRef.current = onSetEditing;
}, [editing, onSetEditing]);
useEffect(
() => () => {
if (editingRef.current) onSetEditingRef.current(false);
},
[],
);
const [editDraft, setEditDraft] = useState(message.Content);
const cancelEditing = useCallback(() => {
setEditDraft(message.Content);
onSetEditing(false);
}, [message.Content, onSetEditing]);
useHotkey(cancelMessageEditHotkey, cancelEditing, {
enabled: editing,
target: messageRef,
});
useEffect(() => {
if (!editing) {
setEditDraft(message.Content);
@ -271,12 +296,14 @@ function MessageComponent({
return (
<div
ref={messageRef}
// pt-3 is to get a gap between messages
className={`${grouped ? "" : "pt-3"} w-full flex flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
>
{/* reply */}
{replyMessage && replyUser && (
<ReplyBox
edited={replyMessage.Edited}
content={replyMessage.Content}
user={replyUser}
variant="message"
@ -289,7 +316,7 @@ function MessageComponent({
isOwnMessage={message.SenderId === ownId}
messageId={message.SendTime}
onReact={toggleReaction}
onSetEditing={setEditing}
onSetEditing={onSetEditing}
>
<>
<div
@ -305,7 +332,7 @@ function MessageComponent({
>
<>
{grouped ? (
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
<p className="w-9 self-start pt-[5px] text-xs group-hover:visible invisible text-muted-foreground">
{new Date(message.SendTime).toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
@ -362,13 +389,17 @@ function MessageComponent({
{editing ? (
<div className="flex w-full flex-col gap-1">
<Input
autoFocus
className="w-full"
styled
setValue={setEditDraft}
value={editDraft}
onSubmit={() => {
submitEditMessage(editDraft);
setEditing(false);
if (!editDraft.trim()) return;
if (editDraft !== message.Content) {
void submitEditMessage(editDraft);
}
onSetEditing(false);
}}
/>
<div className="flex gap-1">
@ -377,13 +408,12 @@ function MessageComponent({
variant="link"
className="text-primary-foreground-alt"
onClick={() => {
if (editDraft === message.Content) {
deleteMessage(message.SendTime);
} else {
submitEditMessage(editDraft);
if (!editDraft.trim()) return;
if (editDraft !== message.Content) {
void submitEditMessage(editDraft);
}
setEditDraft(message.Content);
setEditing(false);
onSetEditing(false);
}}
>
Save
@ -392,19 +422,25 @@ function MessageComponent({
size="xs"
variant="link"
className="text-muted-foreground"
onClick={() => {
setEditDraft(message.Content);
setEditing(false);
}}
onClick={cancelEditing}
>
Cancel
</Button>
</div>
</div>
) : isValidURL ? (
<Media link={message.Content} />
) : (
<Text value={message.Content} />
<div className="flex gap-1">
{isValidURL ? (
<Media link={message.Content} />
) : (
<Text value={message.Content} />
)}
{message.Edited && (
<p className="text-xs text-muted-foreground self-center">
(edited)
</p>
)}
</div>
)}
{groupedReactions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1 pb-1">
@ -445,6 +481,7 @@ function MessageComponent({
export default memo(MessageComponent, (prev, next) => {
return (
prev.message.SendTime === next.message.SendTime &&
prev.editing === next.editing &&
prev.message.Content === next.message.Content &&
prev.message.SenderId === next.message.SenderId &&
prev.message.ReplyId === next.message.ReplyId &&

View file

@ -28,6 +28,7 @@ function ReplyUser({ user }: { user: User }) {
}
export default function ReplyBox({
edited,
content,
loading = false,
onDismiss,
@ -35,6 +36,7 @@ export default function ReplyBox({
userId,
variant,
}: {
edited?: boolean;
content?: string;
loading?: boolean;
onDismiss?: () => void;
@ -74,8 +76,13 @@ export default function ReplyBox({
/>
) : null}
{content !== undefined && (
<div className="h-5.5 min-w-0 flex-1 truncate">
<div className="h-5.5 min-w-0 flex-1 truncate flex gap-1">
<Text fontSize="0.88rem" value={content} />
{edited && (
<p className="text-xs text-muted-foreground self-center">
(edited)
</p>
)}
</div>
)}
</>

View file

@ -27,7 +27,7 @@ import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { useSession } from "@tensamin/storage/session";
import { useUser } from "@tensamin/user/context";
import { createCache } from "@tensamin/cache";
import { createCache, type ChatDraft } from "@tensamin/cache";
import { secureValueCodec } from "@tensamin/storage/secure";
export const context = createContext<contextType | undefined>(undefined);
@ -145,7 +145,16 @@ type SendMessageGet = (
data: { SendTime: number },
) => Promise<{ data: RawMessage }>;
type GetChatSecret = (userId: number) => Promise<Uint8Array | null>;
type StoredDraftState = ChatDraft & {
accountId: number;
userId: number;
loaded: boolean;
revision: number;
};
function draftKey(accountId: number, userId: number) {
return `${accountId}:${userId}`;
}
export async function getMessage({
sendTime,
@ -193,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,
@ -226,6 +235,10 @@ export default function Provider({ children }: { children: ReactNode }) {
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
const [ownId, setOwnId] = useState(0);
const [drafts, setDrafts] = useState<Record<string, StoredDraftState>>({});
const draftsRef = useRef<Record<string, StoredDraftState>>({});
const loadingDraftsRef = useRef(new Set<string>());
const draftWriteQueuesRef = useRef(new Map<string, Promise<void>>());
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
userId: number;
value: Uint8Array | null;
@ -258,6 +271,142 @@ export default function Provider({ children }: { children: ReactNode }) {
load("user_id").then(setOwnId);
}, [load]);
const persistDraft = useCallback(
(key: string, accountId: number, userId: number, draft: ChatDraft) => {
const previous =
draftWriteQueuesRef.current.get(key) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(async () => {
const cache = createCache(String(accountId), {
codec: secureValueCodec,
});
if (draft.Content === "" && draft.ReplyId === undefined) {
await cache.drafts.delete(userId);
} else {
await cache.drafts.put(userId, draft);
}
})
.catch((err) => {
log(1, "chat", "red", "Failed to cache chat draft", err);
});
draftWriteQueuesRef.current.set(key, next);
},
[],
);
const updateDraft = useCallback(
(
accountId: number,
userId: number,
update: (current: ChatDraft) => ChatDraft,
) => {
const key = draftKey(accountId, userId);
const current = draftsRef.current[key] ?? {
accountId,
userId,
Content: "",
loaded: false,
revision: 0,
};
const changed = update(current);
const next: StoredDraftState = {
...current,
...changed,
revision: current.revision + 1,
};
const nextDrafts = { ...draftsRef.current, [key]: next };
draftsRef.current = nextDrafts;
setDrafts(nextDrafts);
if (next.loaded) {
persistDraft(key, accountId, userId, {
Content: next.Content,
ReplyId: next.ReplyId,
});
}
},
[persistDraft],
);
useEffect(() => {
if (
!Number.isSafeInteger(ownId) ||
ownId <= 0 ||
!Number.isSafeInteger(userIdValue) ||
userIdValue <= 0
) {
return;
}
const key = draftKey(ownId, userIdValue);
if (draftsRef.current[key]?.loaded || loadingDraftsRef.current.has(key)) {
return;
}
loadingDraftsRef.current.add(key);
void (async () => {
let stored: ChatDraft | undefined;
try {
stored = await createCache(String(ownId), {
codec: secureValueCodec,
}).drafts.get(userIdValue);
} catch (err) {
log(1, "chat", "red", "Failed to restore chat draft", err);
} finally {
const current = draftsRef.current[key];
const next: StoredDraftState =
current && current.revision > 0
? { ...current, loaded: true }
: {
accountId: ownId,
userId: userIdValue,
Content: stored?.Content ?? "",
ReplyId: stored?.ReplyId,
loaded: true,
revision: 0,
};
const nextDrafts = { ...draftsRef.current, [key]: next };
draftsRef.current = nextDrafts;
setDrafts(nextDrafts);
loadingDraftsRef.current.delete(key);
if (next.revision > 0) {
persistDraft(key, ownId, userIdValue, {
Content: next.Content,
ReplyId: next.ReplyId,
});
}
}
})();
}, [ownId, persistDraft, userIdValue]);
const activeDraftKey =
ownId > 0 && userIdValue > 0 ? draftKey(ownId, userIdValue) : undefined;
const activeDraft = activeDraftKey ? drafts[activeDraftKey] : undefined;
const composerValue = activeDraft?.Content ?? "";
const replyTo = activeDraft?.ReplyId;
const setComposerValue = useCallback(
(value: string) => {
if (ownId <= 0 || userIdValue <= 0) return;
updateDraft(ownId, userIdValue, (current) => ({
...current,
Content: value,
}));
},
[ownId, updateDraft, userIdValue],
);
const setReplyTo = useCallback(
(value: number | undefined) => {
if (ownId <= 0 || userIdValue <= 0) return;
updateDraft(ownId, userIdValue, (current) => ({
...current,
ReplyId: value,
}));
},
[ownId, updateDraft, userIdValue],
);
useEffect(() => {
if (!userIdValue) return;
@ -907,12 +1056,6 @@ export default function Provider({ children }: { children: ReactNode }) {
userIdValue,
]);
// Replys
const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
useEffect(() => {
setReplyTo(undefined);
}, [userIdValue]);
return (
<QueryClientProvider client={queryClient}>
<context.Provider
@ -927,10 +1070,13 @@ export default function Provider({ children }: { children: ReactNode }) {
removeReaction,
clearLiveMessages,
chatSecret: currentChatSecret,
ownId,
userId: userIdValue,
inputBoxRef,
error,
errorDescription,
composerValue,
setComposerValue,
replyTo,
setReplyTo,
}}
@ -954,10 +1100,13 @@ type contextType = {
removeReaction: (sendTime: number, reaction: string) => Promise<void>;
clearLiveMessages: () => void;
chatSecret: Uint8Array | null;
ownId: number;
userId: number;
inputBoxRef: React.RefObject<HTMLDivElement | null>;
error: string;
errorDescription: string;
composerValue: string;
setComposerValue: (value: string) => void;
replyTo: number | undefined;
setReplyTo: (value: number | undefined) => void;
};

View file

@ -0,0 +1,17 @@
import { defineHotkey } from "@tensamin/hotkeys";
export const editLastMessageHotkey = defineHotkey({
id: "chat.edit-last-message",
name: "Edit last message",
description: "Edit your most recent message when the composer is empty.",
category: "Chat",
defaultBinding: "ArrowUp",
});
export const cancelMessageEditHotkey = defineHotkey({
id: "chat.cancel-message-edit",
name: "Cancel message edit",
description: "Close the message editor without saving changes.",
category: "Chat",
defaultBinding: "Escape",
});

View file

@ -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,
@ -50,12 +44,69 @@ function getMessageRenderKey(message: RawMessage | LiveMessage) {
return "localId" in message ? message.localId : String(message.SendTime);
}
function isSameDay(first: number | Date, second: number | Date) {
const firstDate = new Date(first);
const secondDate = new Date(second);
return (
firstDate.getFullYear() === secondDate.getFullYear() &&
firstDate.getMonth() === secondDate.getMonth() &&
firstDate.getDate() === secondDate.getDate()
);
}
function formatMessageDate(sendTime: number) {
const date = new Date(sendTime);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (isSameDay(date, today)) {
return "Today";
}
if (isSameDay(date, yesterday)) {
return "Yesterday";
}
const day = String(date.getDate()).padStart(2, "0");
const month = date.toLocaleString([], { month: "long" });
return `${day} ${month} ${date.getFullYear()}`;
}
function DateSeparator({ label }: { label: string }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const timeout = window.setTimeout(() => setVisible(true), 100);
return () => window.clearTimeout(timeout);
}, []);
return (
<div
aria-label={label}
className={`my-3 flex w-full items-center gap-3 px-3 transition-opacity duration-150 ${visible ? "opacity-100" : "opacity-0"}`}
role="separator"
>
<div className="h-px flex-1 bg-border" />
<span className="shrink-0 text-xs font-medium text-muted-foreground">
{label}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
}
function buildMessageChunks(
messages: Array<RawMessage | LiveMessage>,
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);
@ -85,10 +136,15 @@ export default function Screen() {
clearLiveMessages,
userId,
chatSecret,
ownId,
inputBoxRef,
error,
errorDescription,
composerValue,
setComposerValue,
} = useChat();
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLDivElement | null>(null);
const topSentinelRef = useRef<HTMLDivElement | null>(null);
const didInitialScrollRef = useRef(false);
const userScrolledUpRef = useRef(false);
@ -102,11 +158,26 @@ export default function Screen() {
const [lastLiveMessageCount, setLastLiveMessageCount] = useState(0);
const [didInitialScroll, setDidInitialScroll] = useState(false);
const [viewportHeight, setViewportHeight] = useState(0);
const [value, setValue] = useState("");
const [composerHeight, setComposerHeight] = useState(0);
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
const previousEditingMessageIdRef = useRef<number | null>(null);
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
const hasChatSecret = chatSecret !== null;
useEffect(() => {
const previousEditingMessageId = previousEditingMessageIdRef.current;
previousEditingMessageIdRef.current = editingMessageId;
if (previousEditingMessageId === null || editingMessageId !== null) return;
const frame = requestAnimationFrame(() => {
inputBoxRef.current
?.querySelector<HTMLElement>(".cm-content")
?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [editingMessageId, inputBoxRef]);
const messagesQuery = useInfiniteQuery({
queryKey: ["chat-messages", String(userId), hasChatSecret],
initialPageParam: 0,
@ -141,6 +212,7 @@ export default function Screen() {
isAtBottomRef.current = true;
setDidInitialScroll(false);
setLastLiveMessageCount(0);
setEditingMessageId(null);
}, [clearLiveMessages, userId]);
const historicalMessages = useMemo(() => {
@ -208,11 +280,40 @@ export default function Screen() {
getItemKey,
estimateSize,
overscan: 2,
paddingStart: composerHeight + 20,
});
const totalSize = virtualizer.getTotalSize();
const contentHeight = Math.max(totalSize, viewportHeight);
const verticalOffset = Math.max(0, viewportHeight - totalSize);
const editLastMessage = useCallback(() => {
if (editingMessageId !== null) return;
const message = [...messages]
.reverse()
.find(
(candidate) =>
candidate.SenderId === ownId &&
candidate.Content.length > 0 &&
candidate.MessageState !== "awaiting" &&
!("failed" in candidate && candidate.failed) &&
!("decryptionFailed" in candidate && candidate.decryptionFailed),
);
if (!message) return;
const chunkIndex = messageChunks.findIndex((chunk) =>
chunk.messages.some(
(candidate) => candidate.SendTime === message.SendTime,
),
);
const chunkIsRendered = virtualizer
.getVirtualItems()
.some(({ index }) => index === chunkIndex);
if (chunkIndex >= 0 && !chunkIsRendered) {
virtualizer.scrollToIndex(chunkIndex, { align: "center" });
}
setEditingMessageId(message.SendTime);
}, [editingMessageId, messageChunks, messages, ownId, virtualizer]);
useLayoutEffect(() => {
const element = scrollRef.current;
if (!element || typeof ResizeObserver === "undefined") {
@ -233,6 +334,26 @@ export default function Screen() {
};
}, []);
useLayoutEffect(() => {
const element = composerRef.current;
if (!element || typeof ResizeObserver === "undefined") {
return;
}
const updateComposerHeight = () => {
setComposerHeight(element.getBoundingClientRect().height);
};
updateComposerHeight();
const observer = new ResizeObserver(updateComposerHeight);
observer.observe(element);
return () => {
observer.disconnect();
};
}, []);
useLayoutEffect(() => {
if (didInitialScrollRef.current || virtualRowCount === 0) {
return;
@ -282,22 +403,6 @@ export default function Screen() {
void messagesQuery.fetchNextPage();
}, [didInitialScroll, messagesQuery, totalSize, viewportHeight]);
useLayoutEffect(() => {
if (
!didInitialScroll ||
userScrolledUpRef.current ||
virtualRowCount === 0
) {
return;
}
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
});
}, [didInitialScroll, virtualRowCount]);
useEffect(() => {
const root = scrollRef.current;
const sentinel = topSentinelRef.current;
@ -436,7 +541,7 @@ export default function Screen() {
className="min-h-0 flex-1 overflow-y-auto"
style={{
overflowAnchor: "none",
paddingTop: "22px",
//paddingTop: "22px",
transform: "scaleY(-1)",
}}
onScroll={handleContainerScroll}
@ -475,8 +580,15 @@ export default function Screen() {
const messageIndex =
chunk.startIndex + chunkMessageIndex;
const lastMessage = messages[messageIndex - 1];
const startsNewDay =
!lastMessage ||
!isSameDay(lastMessage.SendTime, message.SendTime);
const dateLabel = startsNewDay
? formatMessageDate(message.SendTime)
: null;
const isGrouped =
lastMessage &&
!startsNewDay &&
!message.ReplyId &&
lastMessage.SenderId === message.SenderId &&
Math.round(lastMessage.SendTime / 10000) ===
@ -488,11 +600,24 @@ export default function Screen() {
userId={message.SenderId}
loading={null}
component={(user) => (
<Message
grouped={isGrouped}
message={message}
user={user}
/>
<>
{dateLabel && (
<DateSeparator label={dateLabel} />
)}
<Message
editing={
editingMessageId === message.SendTime
}
grouped={isGrouped}
message={message}
onSetEditing={(editing) =>
setEditingMessageId(
editing ? message.SendTime : null,
)
}
user={user}
/>
</>
)}
/>
);
@ -503,8 +628,12 @@ export default function Screen() {
})}
</div>
</div>
<div className="z-10 shrink-0">
<InputComponent setValue={setValue} value={value} />
<div ref={composerRef} className="absolute inset-x-0 bottom-0 z-10">
<InputComponent
onEditLastMessage={editLastMessage}
setValue={setComposerValue}
value={composerValue}
/>
</div>
</>
)}

View file

@ -1,12 +1,9 @@
- Implement context menu features
- Forward
- Pin Message
- Reply
- Add default-emoji-hotkey
- Placeholder image if media fails to load
- Signature verifications via ed25519 key
- Confirmation when exiting with text in the input box.
- Add arrow up hotkey to edit last message (req: packages/hotkeys)
- Drop any unique reactions above 10
- Reply jumping
- Add emoji picker

View file

@ -0,0 +1,24 @@
{
"name": "@tensamin/hotkeys",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"test": "vitest run --passWithNoTests",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tanstack/react-hotkeys": "^0.10.0",
"@tensamin/storage": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"vite": "^8.0.10"
}
}

View file

@ -0,0 +1,252 @@
import {
HotkeysProvider as TanStackHotkeysProvider,
useHotkeys as useTanStackHotkeys,
type Hotkey,
type UseHotkeyOptions,
} from "@tanstack/react-hotkeys";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useStorage } from "@tensamin/storage/context";
import {
getHotkeyDefinitions,
normalizeHotkeyOverrides,
toElectronAccelerator,
type HotkeyDefinition,
} from "./registry";
type GlobalRegistrationStatus = "registered" | "unavailable";
type HotkeysContextValue = {
overrides: Record<string, Hotkey | null>;
bindingFor: (definition: HotkeyDefinition) => Hotkey | null;
setBinding: (definition: HotkeyDefinition, binding: Hotkey | null) => void;
resetBinding: (definition: HotkeyDefinition) => void;
resetAll: () => void;
globalStatuses: Record<string, GlobalRegistrationStatus>;
setRecording: (recording: boolean) => void;
registerGlobalHandler: (
definition: HotkeyDefinition,
handler: () => void,
) => () => void;
};
const HotkeysContext = createContext<HotkeysContextValue | undefined>(
undefined,
);
export function HotkeysProvider({ children }: { children: ReactNode }) {
const { load, save } = useStorage();
const [overrides, setOverrides] = useState<Record<string, Hotkey | null>>({});
const [handlersRevision, setHandlersRevision] = useState(0);
const [globalStatuses, setGlobalStatuses] = useState<
Record<string, GlobalRegistrationStatus>
>({});
const handlers = useRef(new Map<string, Set<() => void>>());
const overridesRef = useRef<Record<string, Hotkey | null>>({});
useEffect(() => {
let active = true;
void load("hotkey_overrides")
.then((stored) => {
if (!active) return;
const next = normalizeHotkeyOverrides(stored);
overridesRef.current = next;
setOverrides(next);
})
.catch((error: unknown) => {
console.error("Failed to load hotkey settings", error);
});
return () => {
active = false;
};
}, [load]);
const persist = useCallback(
(next: Record<string, Hotkey | null>) => {
overridesRef.current = next;
setOverrides(next);
void save("hotkey_overrides", next).catch((error: unknown) => {
console.error("Failed to save hotkey settings", error);
});
},
[save],
);
const bindingFor = useCallback(
(definition: HotkeyDefinition) =>
Object.hasOwn(overrides, definition.id)
? (overrides[definition.id] ?? null)
: definition.defaultBinding,
[overrides],
);
const setBinding = useCallback(
(definition: HotkeyDefinition, binding: Hotkey | null) => {
const next = { ...overridesRef.current };
if (binding === definition.defaultBinding) delete next[definition.id];
else next[definition.id] = binding;
persist(next);
},
[persist],
);
const resetBinding = useCallback(
(definition: HotkeyDefinition) => {
const next = { ...overridesRef.current };
delete next[definition.id];
persist(next);
},
[persist],
);
const resetAll = useCallback(() => persist({}), [persist]);
const registerGlobalHandler = useCallback(
(definition: HotkeyDefinition, handler: () => void) => {
const current = handlers.current.get(definition.id) ?? new Set();
current.add(handler);
handlers.current.set(definition.id, current);
setHandlersRevision((revision) => revision + 1);
return () => {
current.delete(handler);
if (current.size === 0) handlers.current.delete(definition.id);
setHandlersRevision((revision) => revision + 1);
};
},
[],
);
useEffect(() => {
return window.tensaminDesktop?.hotkeys?.onTriggered?.((id) => {
handlers.current.get(id)?.forEach((handler) => handler());
});
}, []);
useEffect(() => {
const desktopHotkeys = window.tensaminDesktop?.hotkeys;
if (!desktopHotkeys?.setBindings) return;
const unsupported: string[] = [];
const registrations = getHotkeyDefinitions().flatMap((definition) => {
if (!definition.global || !handlers.current.has(definition.id)) return [];
const binding = bindingFor(definition);
const accelerator = binding && toElectronAccelerator(binding);
if (binding && !accelerator) unsupported.push(definition.id);
return accelerator ? [{ id: definition.id, accelerator }] : [];
});
let active = true;
void desktopHotkeys
.setBindings(registrations)
.then((statuses) => {
if (!active) return;
setGlobalStatuses(
Object.fromEntries(
[
...unsupported.map((id) => [id, false] as const),
...Object.entries(statuses),
].map(([id, registered]) => [
id,
registered ? "registered" : "unavailable",
]),
),
);
})
.catch((error: unknown) => {
console.error("Failed to register global hotkeys", error);
});
return () => {
active = false;
};
}, [bindingFor, handlersRevision]);
const setRecording = useCallback((recording: boolean) => {
void window.tensaminDesktop?.hotkeys
?.setSuspended?.(recording)
.catch((error: unknown) => {
console.error("Failed to suspend global hotkeys", error);
});
}, []);
const value = useMemo<HotkeysContextValue>(
() => ({
overrides,
bindingFor,
setBinding,
resetBinding,
resetAll,
globalStatuses,
setRecording,
registerGlobalHandler,
}),
[
bindingFor,
globalStatuses,
overrides,
registerGlobalHandler,
resetAll,
resetBinding,
setBinding,
setRecording,
],
);
return (
<TanStackHotkeysProvider>
<HotkeysContext value={value}>{children}</HotkeysContext>
</TanStackHotkeysProvider>
);
}
export function useHotkeysContext() {
const value = useContext(HotkeysContext);
if (!value)
throw new Error("useHotkeysContext must be used within HotkeysProvider");
return value;
}
export function useHotkey(
definition: HotkeyDefinition,
callback: () => void,
options: UseHotkeyOptions = {},
) {
const { bindingFor, registerGlobalHandler } = useHotkeysContext();
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const binding = bindingFor(definition);
const handledByElectron = Boolean(
definition.global && window.tensaminDesktop?.hotkeys,
);
useTanStackHotkeys(
binding && !handledByElectron && options.enabled !== false
? [
{
hotkey: binding,
callback: () => callbackRef.current(),
options: {
...options,
meta: {
name: definition.name,
description: definition.description,
},
},
},
]
: [],
);
useEffect(() => {
if (!definition.global || options.enabled === false || !binding) return;
return registerGlobalHandler(definition, () => callbackRef.current());
}, [binding, definition, options.enabled, registerGlobalHandler]);
}

View file

@ -0,0 +1,14 @@
export { HotkeysProvider, useHotkey, useHotkeysContext } from "./context";
export {
defineHotkey,
getHotkeyDefinitions,
normalizeHotkeyOverrides,
toElectronAccelerator,
useHotkeyDefinitions,
type HotkeyDefinition,
} from "./registry";
export {
formatForDisplay,
useHotkeyRecorder,
type Hotkey,
} from "@tanstack/react-hotkeys";

View file

@ -0,0 +1,94 @@
import { useSyncExternalStore } from "react";
import { validateHotkey, type Hotkey } from "@tanstack/react-hotkeys";
export type HotkeyDefinition = Readonly<{
id: string;
name: string;
description?: string;
category: string;
defaultBinding: Hotkey;
global?: boolean;
}>;
const definitions = new Map<string, HotkeyDefinition>();
const listeners = new Set<() => void>();
let snapshot: HotkeyDefinition[] = [];
export function defineHotkey(definition: HotkeyDefinition) {
const existing = definitions.get(definition.id);
if (existing) return existing;
definitions.set(definition.id, Object.freeze({ ...definition }));
snapshot = [...definitions.values()];
listeners.forEach((listener) => listener());
return definitions.get(definition.id)!;
}
export function getHotkeyDefinitions() {
return snapshot;
}
export function useHotkeyDefinitions() {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
getHotkeyDefinitions,
getHotkeyDefinitions,
);
}
export function normalizeHotkeyOverrides(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value).filter(
([id, binding]) =>
id.length > 0 &&
id.length <= 128 &&
(binding === null ||
(typeof binding === "string" &&
binding.length > 0 &&
binding.length <= 128 &&
validateHotkey(binding).valid)),
),
) as Record<string, Hotkey | null>;
}
export function toElectronAccelerator(hotkey: Hotkey) {
const keyAliases: Record<string, string> = {
ArrowDown: "Down",
ArrowLeft: "Left",
ArrowRight: "Right",
ArrowUp: "Up",
" ": "Space",
};
const modifierAliases: Record<string, string> = {
Alt: "Alt",
Control: "Control",
Ctrl: "Control",
Meta: "Command",
Mod: "CommandOrControl",
Shift: "Shift",
};
const parts = hotkey.split("+");
if (parts.length === 0) return null;
const key = parts.at(-1)!;
const modifiers = parts.slice(0, -1).map((part) => modifierAliases[part]);
if (modifiers.some((part) => !part)) return null;
const acceleratorKey = keyAliases[key] ?? key;
if (
!/^[A-Za-z0-9]$/.test(acceleratorKey) &&
!keyAliases[key] &&
!/^(Backspace|Delete|End|Enter|Escape|F([1-9]|1[0-9]|2[0-4])|Home|PageDown|PageUp|Space|Tab)$/.test(
acceleratorKey,
)
) {
return null;
}
return [...modifiers, acceleratorKey].join("+");
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View file

@ -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];

View file

@ -52,6 +52,12 @@ import Emoji, {
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
export type InputController = {
focus: () => void;
hasFocus: () => boolean;
insertText: (text: string) => void;
};
export type InputProps = {
ref?: HTMLDivElement;
placeholder?: string;
@ -66,10 +72,8 @@ export type InputProps = {
className?: string;
emojiFrequencies?: Readonly<Record<string, number>>;
onEmojiSelect?: (shortcode: string) => void;
};
type InputStyle = CSSProperties & {
"--tm-md-content-padding"?: string;
autoFocus?: boolean;
onControllerChange?: (controller: InputController | null) => void;
};
function toCssLength(value: CSSProperties["padding"]): string | undefined {
@ -91,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" });
@ -316,6 +315,7 @@ export default function Input(props: InputProps) {
const elementRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | undefined>(undefined);
const setValueRef = useRef(props.setValue);
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
props.onEmojiSelect,
@ -326,10 +326,16 @@ export default function Input(props: InputProps) {
const completionCompartment = completionCompartmentRef.current;
useEffect(() => {
setValueRef.current = props.setValue;
onSubmitRef.current = props.onSubmit;
onEmojiSelectRef.current = props.onEmojiSelect;
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
}, [props.onEmojiSelect, props.onSubmit, props.invertEnterBehavior]);
}, [
props.onEmojiSelect,
props.onSubmit,
props.invertEnterBehavior,
props.setValue,
]);
useEffect(() => {
if (!elementRef.current) return;
@ -338,7 +344,7 @@ export default function Input(props: InputProps) {
doc: props.value,
extensions: createEditorExtensions(
(value) => {
props.setValue(value);
setValueRef.current(value);
},
() => props.placeholder,
() => invertEnterBehaviorRef.current,
@ -353,8 +359,23 @@ export default function Input(props: InputProps) {
state,
parent: elementRef.current,
});
props.onControllerChange?.({
focus: () => viewRef.current?.contentDOM.focus({ preventScroll: true }),
hasFocus: () => viewRef.current?.hasFocus ?? false,
insertText: (text) => {
const editor = viewRef.current;
if (!editor) return;
editor.dispatch({
...editor.state.replaceSelection(text),
annotations: Transaction.userEvent.of("input.type"),
scrollIntoView: true,
});
},
});
if (props.autoFocus) viewRef.current.focus();
return () => {
props.onControllerChange?.(null);
viewRef.current?.destroy();
viewRef.current = undefined;
};
@ -413,7 +434,9 @@ export default function Input(props: InputProps) {
props.paddingX,
Boolean(props.styled),
),
} as InputStyle
} as CSSProperties & {
"--tm-md-content-padding"?: string;
}
}
/>
);
@ -804,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;

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 { 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;
}
}

View file

@ -21,7 +21,6 @@
"mtp": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tauri-plugin-app-events-api": "^0.2.0",
"zod": "^4.4.2"
},
"devDependencies": {

View file

@ -8,8 +8,8 @@ import {
useRef,
useState,
} from "react";
import { isTauri } from "@tauri-apps/api/core";
import { onResume } from "tauri-plugin-app-events-api";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { MTPClient } from "mtp";
import { type z } from "zod";
import { ConnectionState } from "mtp";
@ -92,10 +92,6 @@ type ContextType = {
const MTPContext = createContext<ContextType | undefined>(undefined);
function isTauriMobile() {
return isTauri() && /Android|iPhone|iPad|iPod/.test(navigator.userAgent);
}
function getProtocolErrorDetails(error: unknown) {
if (typeof error !== "object" || error === null || !("type" in error)) {
return null;
@ -143,7 +139,21 @@ function validateResponse<T extends keyof Schemas & string>(
} as ProtocolMessage<T>;
}
export function Provider(props: {
function useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
const subscribePush = useCallback((handler: PushHandler) => {
pushHandlersRef.current.add(handler);
return () => pushHandlersRef.current.delete(handler);
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
return { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush };
}
function BrowserProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
@ -162,8 +172,8 @@ export function Provider(props: {
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
null,
);
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
useMessageHandlers();
const connected = readyState === ConnectionState.Connected;
@ -203,16 +213,6 @@ export function Provider(props: {
});
}, []);
const subscribePush = useCallback((handler: PushHandler) => {
pushHandlersRef.current.add(handler);
return () => pushHandlersRef.current.delete(handler);
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
// Reconnect stuff
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
@ -223,7 +223,6 @@ export function Provider(props: {
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectScheduled = false;
let disposed = false;
let resumeListenerRegistered = false;
let connectionGeneration = 0;
const clearReconnectTimer = () => {
@ -292,8 +291,6 @@ export function Provider(props: {
setIdentified(false);
setIdentifying(false);
await MTPClient.init();
const [userId, keyring] = await Promise.all([
load("user_id"),
load("mtp_keyring"),
@ -526,37 +523,13 @@ export function Provider(props: {
}
}
async function reconnectAfterResume() {
if (disposed) return;
connectionGeneration += 1;
clientRef.current?.disconnect();
clientRef.current = null;
clearReconnectTimer();
clearReconnectResetTimer();
attempts = 0;
reconnectScheduled = false;
await connect();
}
void connect();
if (!props.blockConnection && isTauriMobile()) {
resumeListenerRegistered = true;
onResume(() => {
void reconnectAfterResume();
});
}
return () => {
disposed = true;
clearReconnectTimer();
clearReconnectResetTimer();
if (resumeListenerRegistered) {
onResume();
}
clientRef.current?.disconnect();
clientRef.current = null;
setReadyState(ConnectionState.Disconnected);
@ -564,7 +537,7 @@ export function Provider(props: {
setIdentifying(false);
sonnerToast.dismiss("mtp-connection-toast");
};
}, [mtpUrl, props.blockConnection, load]);
}, [mtpUrl, props.blockConnection, load, pushHandlersRef]);
// No Iota check
useEffect(() => {
@ -626,7 +599,7 @@ export function Provider(props: {
}
return response;
},
[mtpRef],
[interceptorsRef, mtpRef],
);
return (
@ -650,6 +623,234 @@ export function Provider(props: {
);
}
type NativeSnapshot = {
generation: number;
readyState: number;
identified: boolean;
state?: unknown;
error?: string;
};
function TauriProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const [snapshot, setSnapshot] = useState<NativeSnapshot>({
generation: 0,
readyState: ConnectionState.Disconnected,
identified: false,
});
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const generationRef = useRef(0);
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
useMessageHandlers();
const subscriptionsRef = useRef(
new Map<string, Set<(message: ProtocolMessage) => void>>(),
);
const applySnapshot = useCallback((next: NativeSnapshot) => {
if (next.generation < generationRef.current) return;
generationRef.current = next.generation;
if (next.error) {
log(0, "android", "orange", "MTP connection failed", next.error);
}
setSnapshot(next);
if (!next.identified || next.state === undefined) return;
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
if (!parsed.success) {
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
return;
}
setFreshContacts(parsed.data.Contacts);
setFreshCommunities(parsed.data.Communities);
setFreshCalls(parsed.data.Calls);
}, []);
const dispatchMessage = useCallback(
(raw: unknown) => {
if (!raw || typeof raw !== "object" || !("type" in raw)) return;
const message = raw as { id?: number; type: string; data: unknown };
let validated: ProtocolMessage;
try {
validated = validateResponse(
message.type as keyof Schemas & string,
message,
);
} catch (error) {
log(1, "mtp", "red", "Failed to validate native MTP message", error);
return;
}
for (const handler of subscriptionsRef.current.get(validated.type) ??
[]) {
handler(validated);
}
if (!(PUSH_TYPES as readonly string[]).includes(validated.type)) return;
for (const handler of [...pushHandlersRef.current]) {
void Promise.resolve(handler(validated)).catch((error) => {
log(1, "mtp", "red", "Native MTP push handler failed", error, {
type: validated.type,
});
});
}
},
[pushHandlersRef],
);
useEffect(() => {
if (props.blockConnection) return;
let disposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
unlisten = await listen<
| { kind: "state"; snapshot: NativeSnapshot }
| { kind: "message"; generation: number; message: unknown }
| {
kind: "log";
level: number;
message: string;
details?: unknown;
}
>("mtp://event", ({ payload }) => {
if (disposed) return;
if (payload.kind === "state") {
applySnapshot(payload.snapshot);
return;
}
if (payload.kind === "message") {
if (payload.generation === generationRef.current) {
dispatchMessage(payload.message);
}
return;
}
log(
payload.level,
"android",
"orange",
payload.message,
payload.details,
);
});
const current = await invoke<NativeSnapshot>("mtp_status");
if (!disposed) applySnapshot(current);
})().catch((error) => {
log(0, "mtp", "red", "Failed to initialize native MTP bridge", error);
});
return () => {
disposed = true;
unlisten?.();
};
}, [applySnapshot, dispatchMessage, props.blockConnection]);
useEffect(() => {
if (props.blockConnection) return;
const updateVisibility = () => {
void invoke("mtp_set_ui_visible", {
visible: document.visibilityState === "visible" && document.hasFocus(),
});
};
updateVisibility();
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
window.removeEventListener("focus", updateVisibility);
window.removeEventListener("blur", updateVisibility);
void invoke("mtp_set_ui_visible", { visible: false });
};
}, [props.blockConnection]);
const send = useCallback<BoundSendFn>(
async (type, data, options) => {
const response = await invoke<ProtocolMessage>("mtp_request", {
typeName: type,
data: data ?? {},
id: options?.id,
});
const validated = validateResponse(type, response);
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: validated as ProtocolMessage }),
).catch((error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
});
}
return validated;
},
[interceptorsRef],
);
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
const handlers =
subscriptionsRef.current.get(type) ??
new Set<(message: ProtocolMessage) => void>();
handlers.add(handler as (message: ProtocolMessage) => void);
subscriptionsRef.current.set(type, handlers);
return () => {
handlers.delete(handler as (message: ProtocolMessage) => void);
if (handlers.size === 0) subscriptionsRef.current.delete(type);
};
}, []);
const connected = snapshot.readyState === ConnectionState.Connected;
const contextReady = connected && snapshot.identified;
return (
<MTPContext.Provider
value={{
send,
subscribe,
subscribePush,
addInterceptor,
readyState: snapshot.readyState,
identified: snapshot.identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription: connected
? "Waiting for authenticated session"
: "Establishing native transport channel",
}}
>
{props.children}
</MTPContext.Provider>
);
}
export function Provider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const [wasmReady, setWasmReady] = useState(false);
const [wasmError, setWasmError] = useState<unknown>();
useEffect(() => {
let active = true;
void MTPClient.init().then(
() => {
if (active) setWasmReady(true);
},
(error: unknown) => {
if (active) setWasmError(() => error);
},
);
return () => {
active = false;
};
}, []);
if (wasmError) throw wasmError;
if (!wasmReady) return null;
return isTauri() ? (
<TauriProvider {...props} />
) : (
<BrowserProvider {...props} />
);
}
export function useMTP(): ContextType {
const context = useContext(MTPContext);
if (!context) {

View file

@ -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,
@ -102,12 +102,36 @@ export default function Provider(props: { children: React.ReactNode }) {
const user = await get(data.SenderId);
if (isTauri()) {
if (!appFocused) return;
const permissionGranted =
(await isTauriNotificationPermissionGranted()) ||
(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();

View file

@ -12,6 +12,8 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "~2",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@methanium/ui": "*",

View file

@ -8,10 +8,12 @@ import {
import { useStorage } from "@tensamin/storage/context";
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
import { log } from "@tensamin/shared/log";
import { isTauri } from "@tauri-apps/api/core";
import type { z } from "zod";
import LegalPage from "./pages/legal";
import { onboardingSteps } from "./steps";
import TauriPermissionsPage from "./pages/tauriPermissions";
export {
useOnboardingStep,
@ -19,14 +21,15 @@ 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;
includeOnboarding: boolean;
includeTauriPermissions: boolean;
}
export default function OnboardingGate({ children }: { children: ReactNode }) {
@ -72,12 +75,14 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
acceptedTOS,
onboardingDone,
onboardingStarted,
tauriPermissionsDone,
] = await Promise.all([
load("legal_docs"),
load("accepted_privacy_policy"),
load("accepted_terms_of_service"),
load("onboarding_done"),
load("onboarding_started"),
load("tauri_permissions_done"),
]);
if (!active) return;
@ -104,6 +109,10 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
acceptedTOS: currentAcceptedTOS,
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
includeOnboarding,
includeTauriPermissions:
isTauri() &&
/Android/.test(navigator.userAgent) &&
!tauriPermissionsDone,
});
} catch (caught) {
if (!active) return;
@ -142,8 +151,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
save("onboarding_started", false),
]);
}
if (state?.includeTauriPermissions) {
await save("tauri_permissions_done", true);
}
setComplete(true);
}, [save, state?.includeOnboarding]);
}, [save, state?.includeOnboarding, state?.includeTauriPermissions]);
if (error && errorDescription) {
return <ErrorScreen error={error} description={errorDescription} />;
@ -174,6 +186,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
if (state.includeOnboarding) {
steps.push(...onboardingSteps(onboardingThemeId, setOnboardingThemeId));
}
if (state.includeTauriPermissions) {
steps.push({
id: "tauri-permissions",
title: "Enable notifications",
description:
"We need these permissions so that notifications can be independent of Google Play Services.",
defaultCanContinue: false,
content: <TauriPermissionsPage />,
});
}
if (complete || steps.length === 0) return <>{children}</>;

View file

@ -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>;
@ -32,7 +32,7 @@ export default function LegalPage({
});
return (
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-10 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-2 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-start gap-2">
<BigCheckbox

View file

@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { Button, useOnboardingStep } from "@methanium/ui";
import { invoke } from "@tauri-apps/api/core";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { BatteryCharging, Bell } from "lucide-react";
export default function TauriPermissionsPage() {
const [notificationsGranted, setNotificationsGranted] = useState(false);
const [batteryExempt, setBatteryExempt] = useState(false);
const [notificationAttempted, setNotificationAttempted] = useState(false);
const [batteryAttempted, setBatteryAttempted] = useState(false);
useEffect(() => {
const refresh = () => {
void Promise.all([
isPermissionGranted(),
invoke<boolean>("mtp_is_ignoring_battery_optimizations"),
]).then(([notifications, battery]) => {
setNotificationsGranted(notifications);
setBatteryExempt(battery);
});
};
refresh();
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
const interval = window.setInterval(refresh, 1_000);
return () => {
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
window.clearInterval(interval);
};
}, []);
useOnboardingStep({
canContinue:
(notificationsGranted || notificationAttempted) &&
(batteryExempt || batteryAttempted),
onContinue: async () => {
await invoke("mtp_set_enabled", { enabled: true });
},
});
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-12 py-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div>
<Bell className="h-6! w-6!" />
</div>
<div>
<h3 className="font-semibold">Allow notifications</h3>
<p className="text-muted-foreground text-sm">
Allow Tensamin to notify you while it's closed.
</p>
</div>
</div>
<Button
variant={notificationsGranted ? "outline" : "default"}
disabled={notificationsGranted}
onClick={() => {
setNotificationAttempted(true);
void requestPermission().then((permission) => {
setNotificationsGranted(permission === "granted");
});
}}
>
{notificationsGranted ? "Allowed" : "Allow notifications"}
</Button>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div>
<BatteryCharging className="h-6! w-6!" />
</div>
<div>
<h3 className="font-semibold">Allow running in the background</h3>
<p className="text-muted-foreground text-sm">
Exclude Tensamin from battery optimisation so Android does not
suspend it's connection for decryption of live messages.
</p>
</div>
</div>
<Button
variant={batteryExempt ? "outline" : "default"}
disabled={batteryExempt}
onClick={() => {
setBatteryAttempted(true);
void invoke("mtp_request_battery_exemption");
}}
>
{batteryExempt ? "Allowed" : "Open battery prompt"}
</Button>
</div>
</div>
);
}

View file

@ -14,6 +14,7 @@
"dependencies": {
"@tanstack/react-router": "^1.169.1",
"@tensamin/cache": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",

View file

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

View file

@ -6,6 +6,7 @@ import Licenses from "./pages/licenses";
import Profile from "./pages/profile";
import Security from "./pages/security";
import Theme from "./pages/theme";
import Hotkeys from "./pages/hotkeys";
export const settingsPages = [
{ path: "/", component: Index },
@ -25,6 +26,12 @@ export const settingsPages = [
{ category: "general", path: "call", label: "Call", component: Call },
{ category: "application", path: "cache", label: "Cache", component: Cache },
{ category: "application", path: "theme", label: "Theme", component: Theme },
{
category: "application",
path: "hotkeys",
label: "Hotkeys",
component: Hotkeys,
},
{
category: "application",
path: "licenses",

View file

@ -0,0 +1,152 @@
import { Button, Kbd } from "@methanium/ui";
import {
formatForDisplay,
useHotkeyDefinitions,
useHotkeyRecorder,
useHotkeysContext,
type HotkeyDefinition,
} from "@tensamin/hotkeys";
import { useEffect, useState } from "react";
function HotkeyRow({
definition,
conflicts,
isRecording,
startRecording,
}: {
definition: HotkeyDefinition;
conflicts: string[];
isRecording: boolean;
startRecording: () => void;
}) {
const { bindingFor, setBinding, resetBinding, globalStatuses } =
useHotkeysContext();
const binding = bindingFor(definition);
return (
<div className="flex flex-col gap-2 rounded-lg border border-input p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{definition.name}</p>
{definition.description && (
<p className="text-sm text-muted-foreground">
{definition.description}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{definition.global
? "Global in the Electron desktop app"
: "Active while its screen or component is available"}
</p>
</div>
<Kbd>{binding ? formatForDisplay(binding) : "Unbound"}</Kbd>
</div>
{conflicts.length > 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Also assigned to {conflicts.join(", ")}.
</p>
)}
{definition.global && globalStatuses[definition.id] === "unavailable" && (
<p className="text-sm text-destructive">
Electron could not register this shortcut. It may be reserved by the
operating system or another application.
</p>
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={startRecording}>
{isRecording ? "Press a shortcut..." : "Record"}
</Button>
<Button
variant="outline"
disabled={binding === null}
onClick={() => setBinding(definition, null)}
>
Clear
</Button>
<Button
variant="outline"
disabled={binding === definition.defaultBinding}
onClick={() => resetBinding(definition)}
>
Reset
</Button>
</div>
</div>
);
}
export default function Page() {
const definitions = useHotkeyDefinitions();
const { bindingFor, resetAll, setBinding, setRecording } =
useHotkeysContext();
const [recordingId, setRecordingId] = useState<string | null>(null);
const categories = [...new Set(definitions.map(({ category }) => category))];
const recorder = useHotkeyRecorder({
ignoreInputs: false,
onRecord: (hotkey) => {
const definition = definitions.find(({ id }) => id === recordingId);
if (definition) setBinding(definition, hotkey || null);
setRecordingId(null);
setRecording(false);
},
onCancel: () => {
setRecordingId(null);
setRecording(false);
},
});
useEffect(() => () => setRecording(false), [setRecording]);
return (
<div className="flex max-w-3xl flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Click Record, then press the replacement shortcut. Conflicting
shortcuts are allowed and will run together when their scopes overlap.
</p>
<Button variant="outline" onClick={resetAll}>
Reset All
</Button>
</div>
{categories.map((category) => (
<section className="flex flex-col gap-3" key={category}>
<h2 className="text-lg font-semibold">{category}</h2>
{definitions
.filter((definition) => definition.category === category)
.map((definition) => {
const binding = bindingFor(definition);
const conflicts = binding
? definitions
.filter(
(candidate) =>
candidate.id !== definition.id &&
bindingFor(candidate) === binding,
)
.map(({ name }) => name)
: [];
return (
<HotkeyRow
conflicts={conflicts}
definition={definition}
isRecording={
recordingId === definition.id && recorder.isRecording
}
key={definition.id}
startRecording={() => {
setRecordingId(definition.id);
setRecording(true);
recorder.startRecording();
}}
/>
);
})}
</section>
))}
{definitions.length === 0 && (
<p className="text-sm text-muted-foreground">
No configurable hotkeys are registered.
</p>
)}
</div>
);
}

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 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({
@ -454,6 +436,7 @@ export interface Storage extends SettingsStorageDefaults {
mtp_keyring: string;
onboarding_done: boolean;
onboarding_started: boolean;
tauri_permissions_done: boolean;
ppandtos_done: boolean;
accepted_terms_of_service: boolean;
accepted_privacy_policy: boolean;
@ -471,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";
@ -487,6 +488,7 @@ export interface Storage extends SettingsStorageDefaults {
height: number;
} | null;
reactions: Record<string, number>;
hotkey_overrides: Record<string, string | null>;
}
export const storageDefaults: Storage = {
@ -495,6 +497,7 @@ export const storageDefaults: Storage = {
mtp_keyring: "",
onboarding_done: false,
onboarding_started: false,
tauri_permissions_done: false,
ppandtos_done: false,
accepted_terms_of_service: false,
accepted_privacy_policy: false,
@ -577,6 +580,7 @@ export const storageDefaults: Storage = {
":fire:": 2,
":white_check_mark:": 1,
},
hotkey_overrides: {},
};
// User Status

View file

@ -22,7 +22,11 @@ export type DesktopScreenShareCapabilities = {
hasReliableSystemAudio: boolean;
};
type ElectronDesktopApi = {
declare global {
interface Window {
tensaminDesktop?: {
media?: {
getScreenShareCapabilities?: () => Promise<DesktopScreenShareCapabilities>;
listScreenShareSources?: () => Promise<DesktopScreenShareSource[]>;
@ -38,6 +42,13 @@ type ElectronDesktopApi = {
iconDataUrl?: string;
}) => Promise<void>;
};
hotkeys?: {
setBindings?: (
bindings: Array<{ id: string; accelerator: string }>,
) => Promise<Record<string, boolean>>;
setSuspended?: (suspended: boolean) => Promise<Record<string, boolean>>;
onTriggered?: (callback: (id: string) => void) => () => void;
};
secureStorage?: {
getStatus?: () => Promise<{ available: boolean; backend: string | null }>;
load?: (key: string) => Promise<string | null>;
@ -46,10 +57,6 @@ type ElectronDesktopApi = {
clear?: () => Promise<void>;
};
};
declare global {
interface Window {
tensaminDesktop?: ElectronDesktopApi;
}
}

View file

@ -15,6 +15,7 @@ export function log(
| "red"
| "green"
| "yellow"
| "orange"
| "purple"
| "blue"
| "cyan"
@ -32,6 +33,7 @@ export function log(
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
orange: "\x1b[38;5;208m",
purple: "\x1b[35m",
blue: "\x1b[34m",
cyan: "\x1b[36m",

View file

@ -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 = {

View file

@ -19,6 +19,7 @@ import {
} from "@tensamin/shared/indexedDb";
import { ErrorScreen } from "@methanium/ui";
import { log } from "@tensamin/shared/log";
import { invoke, isTauri } from "@tauri-apps/api/core";
import {
decodeSecureValue,
encodeSecureValue,
@ -94,6 +95,14 @@ export default function StorageProvider(props: { children: ReactNode }) {
const generation = generations.current.get(key) ?? 0;
const request = (async () => {
try {
if (key === "mtp_keyring" && isTauri()) {
const nativeValue = await invoke<string | null>("mtp_load_keyring");
const value = (nativeValue ?? defaults[key]) as StorageSchema[K];
if ((generations.current.get(key) ?? 0) === generation) {
commit(key, value);
}
return value;
}
const desktopStorage = window.tensaminDesktop?.secureStorage;
const desktopStatus = desktopStorage?.getStatus
? await desktopStorage.getStatus()
@ -143,6 +152,10 @@ export default function StorageProvider(props: { children: ReactNode }) {
options: SaveOptions = {},
): Promise<void> => {
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
if (key === "mtp_keyring" && isTauri()) {
commit(key, value);
return;
}
const desktopStorage = window.tensaminDesktop?.secureStorage;
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
if (desktopStorage?.delete)

8698
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

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);
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}.`);