commit
3bc4f49678
73 changed files with 8659 additions and 6399 deletions
1
TODO
1
TODO
|
|
@ -1,5 +1,4 @@
|
||||||
- Add a bunch of tests
|
- Add a bunch of tests
|
||||||
- Add packages/hotkeys/
|
|
||||||
- Full accessability
|
- Full accessability
|
||||||
- Add settings saving & update onboarding to use it
|
- Add settings saving & update onboarding to use it
|
||||||
- Add onboarding page to load one profile during onboarding
|
- Add onboarding page to load one profile during onboarding
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -5,6 +5,7 @@ import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
|
globalShortcut,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
session,
|
session,
|
||||||
shell,
|
shell,
|
||||||
|
|
@ -13,6 +14,7 @@ import { checkForUpdates } from "./updates.js";
|
||||||
import {
|
import {
|
||||||
ipcChannels,
|
ipcChannels,
|
||||||
type DesktopCallStatus,
|
type DesktopCallStatus,
|
||||||
|
type DesktopGlobalHotkeyBinding,
|
||||||
type DesktopScreenShareAudioOutput,
|
type DesktopScreenShareAudioOutput,
|
||||||
type DesktopScreenShareCapabilities,
|
type DesktopScreenShareCapabilities,
|
||||||
} from "../shared/ipc.js";
|
} from "../shared/ipc.js";
|
||||||
|
|
@ -29,6 +31,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const verbose = process.argv.includes("--verbose");
|
const verbose = process.argv.includes("--verbose");
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let selectedScreenShareSourceId: string | null = null;
|
let selectedScreenShareSourceId: string | null = null;
|
||||||
|
let globalHotkeyBindings: DesktopGlobalHotkeyBinding[] = [];
|
||||||
|
let globalHotkeysSuspended = false;
|
||||||
|
|
||||||
app.setName("tensamin");
|
app.setName("tensamin");
|
||||||
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
|
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
|
||||||
|
|
@ -46,6 +50,13 @@ if (
|
||||||
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
|
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.platform === "linux" &&
|
||||||
|
process.env.XDG_SESSION_TYPE === "wayland"
|
||||||
|
) {
|
||||||
|
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.platform === "linux" &&
|
process.platform === "linux" &&
|
||||||
process.env.XDG_SESSION_TYPE === "wayland" &&
|
process.env.XDG_SESSION_TYPE === "wayland" &&
|
||||||
|
|
@ -242,6 +253,24 @@ function registerIpc() {
|
||||||
deleteSecureStorage(key),
|
deleteSecureStorage(key),
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
|
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) => {
|
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
|
||||||
if (
|
if (
|
||||||
typeof status !== "object" ||
|
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() {
|
async function createWindow() {
|
||||||
const rendererIndex = getRendererIndex();
|
const rendererIndex = getRendererIndex();
|
||||||
verboseLog("creating main window", {
|
verboseLog("creating main window", {
|
||||||
|
|
@ -367,6 +480,10 @@ app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
|
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
});
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
console.error("[tensamin:electron] uncaught exception", error);
|
console.error("[tensamin:electron] uncaught exception", error);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
|
||||||
import {
|
import {
|
||||||
ipcChannels,
|
ipcChannels,
|
||||||
type DesktopCallStatus,
|
type DesktopCallStatus,
|
||||||
|
type DesktopGlobalHotkeyBinding,
|
||||||
type DesktopScreenShareSource,
|
type DesktopScreenShareSource,
|
||||||
secureStorageLimits,
|
secureStorageLimits,
|
||||||
} from "../shared/ipc.js";
|
} from "../shared/ipc.js";
|
||||||
|
|
@ -55,6 +56,23 @@ const desktopApi = {
|
||||||
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
|
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: {
|
secureStorage: {
|
||||||
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
|
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
|
||||||
load: (key: string) =>
|
load: (key: string) =>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ export type DesktopSecureStorageStatus = {
|
||||||
backend: string | null;
|
backend: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DesktopGlobalHotkeyBinding = {
|
||||||
|
id: string;
|
||||||
|
accelerator: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const secureStorageLimits = {
|
export const secureStorageLimits = {
|
||||||
maxKeyBytes: 256,
|
maxKeyBytes: 256,
|
||||||
maxValueBytes: 1024 * 1024,
|
maxValueBytes: 1024 * 1024,
|
||||||
|
|
@ -77,4 +82,7 @@ export const ipcChannels = {
|
||||||
saveSecureStorage: "secureStorage:save",
|
saveSecureStorage: "secureStorage:save",
|
||||||
deleteSecureStorage: "secureStorage:delete",
|
deleteSecureStorage: "secureStorage:delete",
|
||||||
clearSecureStorage: "secureStorage:clear",
|
clearSecureStorage: "secureStorage:clear",
|
||||||
|
setGlobalHotkeyBindings: "hotkeys:setBindings",
|
||||||
|
setGlobalHotkeysSuspended: "hotkeys:setSuspended",
|
||||||
|
globalHotkeyTriggered: "hotkeys:triggered",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
2
apps/tauri/.cargo/config.toml
Normal file
2
apps/tauri/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[env]
|
||||||
|
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }
|
||||||
BIN
apps/tauri/monochrome_cropped.png
Normal file
BIN
apps/tauri/monochrome_cropped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
53
apps/tauri/scripts/delete-mobile.ts
Normal file
53
apps/tauri/scripts/delete-mobile.ts
Normal 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);
|
||||||
1563
apps/tauri/src-tauri/Cargo.lock
generated
1563
apps/tauri/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -15,12 +15,18 @@ name = "mobile_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[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]
|
[dependencies]
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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-deep-link = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
|
|
@ -30,10 +36,12 @@ version = "2"
|
||||||
features = []
|
features = []
|
||||||
default-features = true
|
default-features = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = "0.21"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
||||||
tauri-plugin-barcode-scanner = "2"
|
tauri-plugin-barcode-scanner = "2"
|
||||||
tauri-plugin-app-events = "0.2"
|
|
||||||
|
|
||||||
[patch.crates-io.tauri]
|
[patch.crates-io.tauri]
|
||||||
git = "https://github.com/tauri-apps/tauri"
|
git = "https://github.com/tauri-apps/tauri"
|
||||||
branch = "feat/cef"
|
rev = "20f3f2515c65ca1e115991c3ef3625e2c29c1bbb"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"deep-link:default",
|
"deep-link:default",
|
||||||
"app-events:default",
|
|
||||||
"barcode-scanner:default",
|
"barcode-scanner:default",
|
||||||
"barcode-scanner:allow-scan",
|
"barcode-scanner:allow-scan",
|
||||||
"barcode-scanner:allow-cancel",
|
"barcode-scanner:allow-cancel",
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,4 @@
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
<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.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
@ -50,6 +53,25 @@
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaProjection" />
|
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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ class MainActivity : TauriActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWebViewCreate(webView: WebView) {
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
webView.setInitialScale(300)
|
webView.setInitialScale(290)
|
||||||
mediaWebView = webView
|
mediaWebView = webView
|
||||||
MobileMediaEvents.attach(webView)
|
MobileMediaEvents.attach(webView)
|
||||||
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
||||||
|
|
@ -65,10 +65,24 @@ class MainActivity : TauriActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
|
||||||
|
NativeMtpBridge.startService(this)
|
||||||
|
}
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
NativeMtpBridge.nativeAttach(applicationContext)
|
||||||
installKeyboardResizeWorkaround()
|
installKeyboardResizeWorkaround()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
NativeMtpBridge.nativeSetUiState(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
NativeMtpBridge.nativeSetUiState(false)
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
attachLayoutListener?.let { listener ->
|
attachLayoutListener?.let { listener ->
|
||||||
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
|
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
|
@ -1,7 +1,13 @@
|
||||||
|
mod mtp_backend;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let builder = tauri::Builder::default()
|
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());
|
.plugin(tauri_plugin_notification::init());
|
||||||
|
|
||||||
let builder = builder
|
let builder = builder
|
||||||
|
|
@ -11,11 +17,21 @@ pub fn run() {
|
||||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||||
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
|
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
|
||||||
|
|
||||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
let app = builder
|
||||||
let builder = builder.plugin(tauri_plugin_app_events::init());
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
mtp_backend::mtp_request,
|
||||||
if let Err(error) = builder
|
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| {
|
.setup(|_app| {
|
||||||
|
mtp_backend::manager().attach_app(_app.handle().clone());
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
{
|
{
|
||||||
use tauri_plugin_deep_link::DeepLinkExt;
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
|
@ -30,9 +46,18 @@ pub fn run() {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
{
|
.expect("error while building tauri application");
|
||||||
eprintln!("error while running tauri application: {error}");
|
|
||||||
panic!("error while running tauri application: {error}");
|
app.run(|_app, event| {
|
||||||
}
|
#[cfg(target_os = "android")]
|
||||||
|
if let tauri::RunEvent::ExitRequested {
|
||||||
|
api, code: None, ..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
if mtp_backend::manager().is_enabled() {
|
||||||
|
api.prevent_exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1173
apps/tauri/src-tauri/src/mtp_backend.rs
Normal file
1173
apps/tauri/src-tauri/src/mtp_backend.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,11 +9,11 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useIsMobile } from "@methanium/ui";
|
import { useIsMobile } from "@methanium/ui";
|
||||||
|
|
||||||
type DeeplinkContextValue = {
|
|
||||||
deeplinks: readonly string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deeplinkContext = createContext<DeeplinkContextValue | undefined>(
|
|
||||||
|
export const deeplinkContext = createContext<{
|
||||||
|
deeplinks: readonly string[];
|
||||||
|
} | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"@tensamin/cache": "workspace:*",
|
"@tensamin/cache": "workspace:*",
|
||||||
"@tensamin/chat": "workspace:*",
|
"@tensamin/chat": "workspace:*",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
|
"@tensamin/hotkeys": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/settings": "workspace:*",
|
"@tensamin/settings": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { z } from "zod";
|
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 QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
|
@ -64,7 +64,8 @@ function parseTuFileContent(rawFileContent: string): {
|
||||||
throw new Error("Invalid file");
|
throw new Error("Invalid file");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [userIdString, privateKey] = rawFileContent.split("::");
|
const [userIdString, privateKeyValue] = rawFileContent.split("::");
|
||||||
|
const privateKey = privateKeyValue.trim();
|
||||||
const userId = isNaN(Number(userIdString))
|
const userId = isNaN(Number(userIdString))
|
||||||
? Number(userIdString.split("@")[0])
|
? Number(userIdString.split("@")[0])
|
||||||
: Number(userIdString);
|
: Number(userIdString);
|
||||||
|
|
@ -87,7 +88,7 @@ export default function Form() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const { save } = useStorage();
|
const { load, save } = useStorage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const loginPendingRef = useRef(false);
|
const loginPendingRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -97,6 +98,23 @@ export default function Form() {
|
||||||
loginPendingRef.current = true;
|
loginPendingRef.current = true;
|
||||||
try {
|
try {
|
||||||
if (domain) await save("omega_url", `https://${domain}/`);
|
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("mtp_keyring", privateKey, { secure: true });
|
||||||
await save("session_id", Date.now());
|
await save("session_id", Date.now());
|
||||||
await save("user_id", userId);
|
await save("user_id", userId);
|
||||||
|
|
@ -106,7 +124,7 @@ export default function Form() {
|
||||||
loginPendingRef.current = false;
|
loginPendingRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, save],
|
[load, navigate, save],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process dropped files
|
// Process dropped files
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { useCall, useInitializeCall } from "@tensamin/call/store";
|
||||||
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
||||||
import { Provider as MTPProvider } from "@tensamin/mtp";
|
import { Provider as MTPProvider } from "@tensamin/mtp";
|
||||||
import UserProvider from "@tensamin/user/context";
|
import UserProvider from "@tensamin/user/context";
|
||||||
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
|
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
|
||||||
import NotificationsProvider from "@tensamin/notifications/context";
|
import NotificationsProvider from "@tensamin/notifications/context";
|
||||||
|
|
||||||
import TAuthWrapper from "@tensamin/tauth/context";
|
import TAuthWrapper from "@tensamin/tauth/context";
|
||||||
|
|
@ -49,6 +49,7 @@ import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
|
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
import { HotkeysProvider } from "@tensamin/hotkeys";
|
||||||
|
|
||||||
const wrapper = document.getElementById("root");
|
const wrapper = document.getElementById("root");
|
||||||
|
|
||||||
|
|
@ -266,10 +267,12 @@ function RootShell() {
|
||||||
/>
|
/>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Storage>
|
<Storage>
|
||||||
<ThemeStorageBridge />
|
<HotkeysProvider>
|
||||||
<LoginWrapper>
|
<ThemeStorageBridge />
|
||||||
<Outlet />
|
<LoginWrapper>
|
||||||
</LoginWrapper>
|
<Outlet />
|
||||||
|
</LoginWrapper>
|
||||||
|
</HotkeysProvider>
|
||||||
</Storage>
|
</Storage>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -285,6 +288,7 @@ function AppShell() {
|
||||||
<DesktopMediaProvider>
|
<DesktopMediaProvider>
|
||||||
<MTPProvider>
|
<MTPProvider>
|
||||||
<CacheSync />
|
<CacheSync />
|
||||||
|
<DeeplinkNavigator />
|
||||||
<Session>
|
<Session>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<CallInit />
|
<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) {
|
function createCallTrayIcon(color: string, speaking: boolean) {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = 32;
|
canvas.width = 32;
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,7 @@ export default defineConfig({
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
"@tanstack/history",
|
"@tanstack/history",
|
||||||
"@tanstack/react-router",
|
"@tanstack/react-router",
|
||||||
"@tanstack/react-store",
|
|
||||||
"@tanstack/router-core",
|
"@tanstack/router-core",
|
||||||
"@tanstack/store",
|
|
||||||
"@tensamin/crypto",
|
"@tensamin/crypto",
|
||||||
"@tensamin/settings",
|
"@tensamin/settings",
|
||||||
"@tensamin/storage",
|
"@tensamin/storage",
|
||||||
|
|
@ -134,6 +132,7 @@ export default defineConfig({
|
||||||
"@tensamin/chat",
|
"@tensamin/chat",
|
||||||
"@tensamin/crypto",
|
"@tensamin/crypto",
|
||||||
"@tensamin/crypto/context",
|
"@tensamin/crypto/context",
|
||||||
|
"@tensamin/hotkeys",
|
||||||
"@tensamin/markdown",
|
"@tensamin/markdown",
|
||||||
"@tensamin/mtp",
|
"@tensamin/mtp",
|
||||||
"@tensamin/notifications",
|
"@tensamin/notifications",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import * as tsParser from "@typescript-eslint/parser";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
|
inlineSingleUseDeclarations,
|
||||||
noReactNamespaceImport,
|
noReactNamespaceImport,
|
||||||
noWindowLocationReload,
|
noWindowLocationReload,
|
||||||
} from "./utils/eslint-rules/index.js";
|
} from "./utils/eslint-rules/index.js";
|
||||||
|
|
@ -34,6 +35,7 @@ export default [
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
tensamin: {
|
tensamin: {
|
||||||
rules: {
|
rules: {
|
||||||
|
"inline-single-use-declarations": inlineSingleUseDeclarations,
|
||||||
"no-react-namespace-import": noReactNamespaceImport,
|
"no-react-namespace-import": noReactNamespaceImport,
|
||||||
"no-window-location-reload": noWindowLocationReload,
|
"no-window-location-reload": noWindowLocationReload,
|
||||||
},
|
},
|
||||||
|
|
@ -42,6 +44,7 @@ export default [
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"react-hooks/set-state-in-effect": "off",
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
"tensamin/inline-single-use-declarations": "error",
|
||||||
"tensamin/no-react-namespace-import": "error",
|
"tensamin/no-react-namespace-import": "error",
|
||||||
"tensamin/no-window-location-reload": "error",
|
"tensamin/no-window-location-reload": "error",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,8 @@
|
||||||
printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties"
|
printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
adb devices
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "tensamin",
|
"name": "tensamin",
|
||||||
"version": "0.0.10",
|
"version": "0.0.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|
@ -21,12 +21,12 @@
|
||||||
"dev": "cd apps/web && pnpm dev",
|
"dev": "cd apps/web && pnpm dev",
|
||||||
"build:web": "cd apps/web && pnpm run build",
|
"build:web": "cd apps/web && pnpm run build",
|
||||||
"preview:web": "cd apps/web && pnpm run preview",
|
"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",
|
"build:mobile": "cd apps/tauri && pnpm run build:mobile",
|
||||||
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
|
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
|
||||||
"dev:desktop": "cd apps/electron && pnpm run dev",
|
"dev:desktop": "cd apps/electron && pnpm run dev",
|
||||||
"build:desktop": "cd apps/electron && pnpm run package",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
|
||||||
30
packages/cache/src/index.ts
vendored
30
packages/cache/src/index.ts
vendored
|
|
@ -13,9 +13,11 @@ import {
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
import {
|
import {
|
||||||
accountIdSchema,
|
accountIdSchema,
|
||||||
|
chatDraftSchema,
|
||||||
contactsSchema,
|
contactsSchema,
|
||||||
conversationWindowSchema,
|
conversationWindowSchema,
|
||||||
userProfileSchema,
|
userProfileSchema,
|
||||||
|
type ChatDraft,
|
||||||
type Contact,
|
type Contact,
|
||||||
type ConversationWindow,
|
type ConversationWindow,
|
||||||
type UserProfile,
|
type UserProfile,
|
||||||
|
|
@ -24,7 +26,7 @@ import {
|
||||||
export * from "./helpers";
|
export * from "./helpers";
|
||||||
export * from "./schemas";
|
export * from "./schemas";
|
||||||
|
|
||||||
type CacheStore = "contacts" | "profiles" | "conversations";
|
type CacheStore = "contacts" | "profiles" | "conversations" | "drafts";
|
||||||
|
|
||||||
export interface SecureValueCodec {
|
export interface SecureValueCodec {
|
||||||
encode(value: unknown): unknown | Promise<unknown>;
|
encode(value: unknown): unknown | Promise<unknown>;
|
||||||
|
|
@ -237,19 +239,25 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
|
||||||
},
|
},
|
||||||
delete: (userId: number) => remove("conversations", String(userId)),
|
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 () => {
|
clearAccount: async () => {
|
||||||
ensureOpen();
|
ensureOpen();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
(["contacts", "profiles", "conversations"] as CacheStore[]).map(
|
(
|
||||||
async (store) => {
|
["contacts", "profiles", "conversations", "drafts"] as CacheStore[]
|
||||||
const storedEntries = await entries(store);
|
).map(async (store) => {
|
||||||
await Promise.all(
|
const storedEntries = await entries(store);
|
||||||
storedEntries.map(([key]) =>
|
await Promise.all(
|
||||||
deleteDatabaseEntry("cache", storedKey(store, key)),
|
storedEntries.map(([key]) =>
|
||||||
),
|
deleteDatabaseEntry("cache", storedKey(store, key)),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
close: async () => {
|
close: async () => {
|
||||||
|
|
|
||||||
6
packages/cache/src/schemas.ts
vendored
6
packages/cache/src/schemas.ts
vendored
|
|
@ -20,8 +20,14 @@ export const conversationWindowSchema = z.object({
|
||||||
LastMessageAt: z.number(),
|
LastMessageAt: z.number(),
|
||||||
Messages: z.array(cachedMessageSchema),
|
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 Contact = z.infer<typeof contactSchema>;
|
||||||
export type UserProfile = z.infer<typeof userProfileSchema>;
|
export type UserProfile = z.infer<typeof userProfileSchema>;
|
||||||
export type CachedMessage = z.infer<typeof cachedMessageSchema>;
|
export type CachedMessage = z.infer<typeof cachedMessageSchema>;
|
||||||
export type ConversationWindow = z.infer<typeof conversationWindowSchema>;
|
export type ConversationWindow = z.infer<typeof conversationWindowSchema>;
|
||||||
|
export type ChatDraft = z.infer<typeof chatDraftSchema>;
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,6 @@ type MediaShareStoreState = {
|
||||||
cameraSession: LocalMediaShareSession | null;
|
cameraSession: LocalMediaShareSession | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaShareStoreSetState = (
|
|
||||||
updater:
|
|
||||||
| Partial<MediaShareStoreState>
|
|
||||||
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
type MediaShareControllerOptions = {
|
|
||||||
room: Room;
|
|
||||||
getState: () => MediaShareStoreState;
|
|
||||||
setState: MediaShareStoreSetState;
|
|
||||||
getLocalParticipantId: () => number | null;
|
|
||||||
startWatching: (participantId: number) => void;
|
|
||||||
stopWatching: (participantId: number) => void;
|
|
||||||
syncParticipantState: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createMediaShareController({
|
export function createMediaShareController({
|
||||||
room,
|
room,
|
||||||
getState,
|
getState,
|
||||||
|
|
@ -41,7 +25,19 @@ export function createMediaShareController({
|
||||||
startWatching,
|
startWatching,
|
||||||
stopWatching,
|
stopWatching,
|
||||||
syncParticipantState,
|
syncParticipantState,
|
||||||
}: MediaShareControllerOptions) {
|
}: {
|
||||||
|
room: Room;
|
||||||
|
getState: () => MediaShareStoreState;
|
||||||
|
setState: (
|
||||||
|
updater:
|
||||||
|
| Partial<MediaShareStoreState>
|
||||||
|
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
|
||||||
|
) => void;
|
||||||
|
getLocalParticipantId: () => number | null;
|
||||||
|
startWatching: (participantId: number) => void;
|
||||||
|
stopWatching: (participantId: number) => void;
|
||||||
|
syncParticipantState: () => void;
|
||||||
|
}) {
|
||||||
function getSession(kind: MediaShareKind) {
|
function getSession(kind: MediaShareKind) {
|
||||||
return kind === "screen"
|
return kind === "screen"
|
||||||
? getState().screenShareSession
|
? getState().screenShareSession
|
||||||
|
|
|
||||||
|
|
@ -8,32 +8,16 @@ import type {
|
||||||
MediaShareSource,
|
MediaShareSource,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
type MobileMediaApi = {
|
|
||||||
startScreenShare: (includeAudio: boolean) => void;
|
|
||||||
stopScreenShare: () => void;
|
|
||||||
requestCameraPermission: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
tensaminMobileMedia?: MobileMediaApi;
|
tensaminMobileMedia?: {
|
||||||
|
startScreenShare: (includeAudio: boolean) => void;
|
||||||
|
stopScreenShare: () => void;
|
||||||
|
requestCameraPermission: () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FrameDetail = {
|
|
||||||
data: string;
|
|
||||||
mimeType: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AudioDetail = {
|
|
||||||
data: string;
|
|
||||||
sampleRate: number;
|
|
||||||
channelCount: number;
|
|
||||||
encoding: "pcm16le";
|
|
||||||
};
|
|
||||||
|
|
||||||
function eventDetail<T>(event: Event): T {
|
function eventDetail<T>(event: Event): T {
|
||||||
return (event as CustomEvent<T>).detail;
|
return (event as CustomEvent<T>).detail;
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +148,12 @@ async function startMobileScreen(
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFrame = (event: Event) => {
|
const onFrame = (event: Event) => {
|
||||||
const detail = eventDetail<FrameDetail>(event);
|
const detail = eventDetail<{
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}>(event);
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
if (canvas.width !== detail.width || canvas.height !== detail.height) {
|
if (canvas.width !== detail.width || canvas.height !== detail.height) {
|
||||||
|
|
@ -179,7 +168,14 @@ async function startMobileScreen(
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAudio = (event: Event) => {
|
const onAudio = (event: Event) => {
|
||||||
const bytes = decodeBase64(eventDetail<AudioDetail>(event).data);
|
const bytes = decodeBase64(
|
||||||
|
eventDetail<{
|
||||||
|
data: string;
|
||||||
|
sampleRate: number;
|
||||||
|
channelCount: number;
|
||||||
|
encoding: "pcm16le";
|
||||||
|
}>(event).data,
|
||||||
|
);
|
||||||
const samples = new Int16Array(
|
const samples = new Int16Array(
|
||||||
bytes.buffer,
|
bytes.buffer,
|
||||||
bytes.byteOffset,
|
bytes.byteOffset,
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,19 @@ const SPEAKING_HANGTIME_MS = 500;
|
||||||
const ANALYSIS_INTERVAL_MS = 30;
|
const ANALYSIS_INTERVAL_MS = 30;
|
||||||
const FFT_SIZE = 256;
|
const FFT_SIZE = 256;
|
||||||
|
|
||||||
type AnalyserEntry = {
|
|
||||||
source: MediaStreamAudioSourceNode;
|
|
||||||
analyser: AnalyserNode;
|
|
||||||
track: MediaStreamTrack;
|
|
||||||
originalTrack?: MediaStreamTrack;
|
|
||||||
lastSpeakingTime: number;
|
|
||||||
isSpeaking: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
class SpeakingDetector {
|
class SpeakingDetector {
|
||||||
private audioContext: AudioContext | null = null;
|
private audioContext: AudioContext | null = null;
|
||||||
private entries = new Map<number, AnalyserEntry>();
|
private entries = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
source: MediaStreamAudioSourceNode;
|
||||||
|
analyser: AnalyserNode;
|
||||||
|
track: MediaStreamTrack;
|
||||||
|
originalTrack?: MediaStreamTrack;
|
||||||
|
lastSpeakingTime: number;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
}
|
||||||
|
>();
|
||||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
private deaf = false;
|
private deaf = false;
|
||||||
private gateThresholdStart = -50;
|
private gateThresholdStart = -50;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
type SpeakingState = {
|
const useSpeakingState = create<{
|
||||||
speakingParticipantIds: Set<number>;
|
speakingParticipantIds: Set<number>;
|
||||||
lastSpeakingParticipantId: number | null;
|
lastSpeakingParticipantId: number | null;
|
||||||
micGated: boolean;
|
micGated: boolean;
|
||||||
};
|
}>(() => ({
|
||||||
|
|
||||||
const useSpeakingState = create<SpeakingState>(() => ({
|
|
||||||
speakingParticipantIds: new Set(),
|
speakingParticipantIds: new Set(),
|
||||||
lastSpeakingParticipantId: null,
|
lastSpeakingParticipantId: null,
|
||||||
micGated: false,
|
micGated: false,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ setLogExtension(
|
||||||
getLogger("tensamin"),
|
getLogger("tensamin"),
|
||||||
);
|
);
|
||||||
|
|
||||||
type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting";
|
|
||||||
type CallView = "preview" | "focused" | "grid";
|
type CallView = "preview" | "focused" | "grid";
|
||||||
type ProtocolCallSecret = NonNullable<
|
type ProtocolCallSecret = NonNullable<
|
||||||
z.infer<typeof mtp.CallInvite.response>["CallSecret"]
|
z.infer<typeof mtp.CallInvite.response>["CallSecret"]
|
||||||
|
|
@ -63,18 +62,9 @@ type WrappedCallSecret = {
|
||||||
kemCiphertext: Uint8Array;
|
kemCiphertext: Uint8Array;
|
||||||
wrappingScheme: string;
|
wrappingScheme: string;
|
||||||
};
|
};
|
||||||
type IncomingCallInvite = {
|
|
||||||
callId: string;
|
|
||||||
callSecret: WrappedCallSecret;
|
|
||||||
senderId: number;
|
|
||||||
};
|
|
||||||
type CurrentCallData =
|
type CurrentCallData =
|
||||||
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
|
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
|
||||||
|
|
||||||
type NavigateFn = (options: {
|
|
||||||
to: string;
|
|
||||||
search?: Record<string, unknown>;
|
|
||||||
}) => Promise<void>;
|
|
||||||
type SendFn = (
|
type SendFn = (
|
||||||
type: string,
|
type: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
|
|
@ -84,44 +74,15 @@ type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>;
|
||||||
type RemoteVideoTrackSelector = Track.Kind | Track.Source;
|
type RemoteVideoTrackSelector = Track.Kind | Track.Source;
|
||||||
|
|
||||||
type Runtime = {
|
type Runtime = {
|
||||||
navigate: NavigateFn;
|
navigate: (options: {
|
||||||
|
to: string;
|
||||||
|
search?: Record<string, unknown>;
|
||||||
|
}) => Promise<void>;
|
||||||
send: SendFn;
|
send: SendFn;
|
||||||
load: LoadFn;
|
load: LoadFn;
|
||||||
getUser: GetUserFn;
|
getUser: GetUserFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CallStore = {
|
|
||||||
state: CallState;
|
|
||||||
view: CallView;
|
|
||||||
invitedUserId: number | null;
|
|
||||||
callId: string | null;
|
|
||||||
incomingCallInvite: IncomingCallInvite | null;
|
|
||||||
callSecret: string | null;
|
|
||||||
livekitToken: string | null;
|
|
||||||
currentCallData: CurrentCallData;
|
|
||||||
deaf: boolean;
|
|
||||||
micEnabled: boolean;
|
|
||||||
cameraEnabled: boolean;
|
|
||||||
screenShareEnabled: boolean;
|
|
||||||
screenShareSession: LocalMediaShareSession | null;
|
|
||||||
cameraSession: LocalMediaShareSession | null;
|
|
||||||
disabledCameraParticipantIds: number[];
|
|
||||||
focusedParticipantId: number | null;
|
|
||||||
focusedParticipantType: "user" | "stream" | null;
|
|
||||||
usersInFocusedViewHidden: boolean;
|
|
||||||
watchedStreamParticipantIds: number[];
|
|
||||||
pendingWatchedParticipantIds: number[];
|
|
||||||
activeScreenShareParticipantIds: number[];
|
|
||||||
isEncrypted: boolean;
|
|
||||||
ownCallSecretInvitePending: boolean;
|
|
||||||
callIsFullscreen: boolean;
|
|
||||||
callIsPopout: boolean;
|
|
||||||
layoutVersion: number;
|
|
||||||
screenRef: React.RefObject<HTMLDivElement | null> | null;
|
|
||||||
runtime: Runtime | null;
|
|
||||||
lastFocusedParticipantId: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
let _keyProvider: ExternalE2EEKeyProvider | null = null;
|
let _keyProvider: ExternalE2EEKeyProvider | null = null;
|
||||||
let _e2eeWorker: Worker | null = null;
|
let _e2eeWorker: Worker | null = null;
|
||||||
let _room: Room | null = null;
|
let _room: Room | null = null;
|
||||||
|
|
@ -1195,7 +1156,41 @@ async function ensureNoiseFilter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCall = create<CallStore>(() => ({
|
export const useCall = create<{
|
||||||
|
state: "closed" | "closing" | "connecting" | "open" | "encrypting";
|
||||||
|
view: CallView;
|
||||||
|
invitedUserId: number | null;
|
||||||
|
callId: string | null;
|
||||||
|
incomingCallInvite: {
|
||||||
|
callId: string;
|
||||||
|
callSecret: WrappedCallSecret;
|
||||||
|
senderId: number;
|
||||||
|
} | null;
|
||||||
|
callSecret: string | null;
|
||||||
|
livekitToken: string | null;
|
||||||
|
currentCallData: CurrentCallData;
|
||||||
|
deaf: boolean;
|
||||||
|
micEnabled: boolean;
|
||||||
|
cameraEnabled: boolean;
|
||||||
|
screenShareEnabled: boolean;
|
||||||
|
screenShareSession: LocalMediaShareSession | null;
|
||||||
|
cameraSession: LocalMediaShareSession | null;
|
||||||
|
disabledCameraParticipantIds: number[];
|
||||||
|
focusedParticipantId: number | null;
|
||||||
|
focusedParticipantType: "user" | "stream" | null;
|
||||||
|
usersInFocusedViewHidden: boolean;
|
||||||
|
watchedStreamParticipantIds: number[];
|
||||||
|
pendingWatchedParticipantIds: number[];
|
||||||
|
activeScreenShareParticipantIds: number[];
|
||||||
|
isEncrypted: boolean;
|
||||||
|
ownCallSecretInvitePending: boolean;
|
||||||
|
callIsFullscreen: boolean;
|
||||||
|
callIsPopout: boolean;
|
||||||
|
layoutVersion: number;
|
||||||
|
screenRef: React.RefObject<HTMLDivElement | null> | null;
|
||||||
|
runtime: Runtime | null;
|
||||||
|
lastFocusedParticipantId: number | null;
|
||||||
|
}>(() => ({
|
||||||
state: "closed",
|
state: "closed",
|
||||||
view: "preview",
|
view: "preview",
|
||||||
invitedUserId: null,
|
invitedUserId: null,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"@tanstack/react-router": "^1.0.0",
|
"@tanstack/react-router": "^1.0.0",
|
||||||
"@tanstack/react-virtual": "^3.0.0",
|
"@tanstack/react-virtual": "^3.0.0",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
|
"@tensamin/hotkeys": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -34,35 +34,25 @@ function getColumnCount(width: number, itemCount: number) {
|
||||||
|
|
||||||
type KlipyKind = "gif" | "meme";
|
type KlipyKind = "gif" | "meme";
|
||||||
|
|
||||||
type KlipyMediaFile = {
|
|
||||||
url?: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KlipyMediaFormats = Record<string, KlipyMediaFile | undefined>;
|
|
||||||
|
|
||||||
type KlipyItem = {
|
type KlipyItem = {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
title?: string;
|
title?: string;
|
||||||
file?: Record<string, KlipyMediaFormats | undefined>;
|
file?: Record<
|
||||||
|
string,
|
||||||
|
| Record<
|
||||||
|
string,
|
||||||
|
| {
|
||||||
|
url?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>
|
||||||
|
| undefined
|
||||||
|
>;
|
||||||
blur_preview?: string;
|
blur_preview?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type KlipyPage = {
|
|
||||||
items: KlipyItem[];
|
|
||||||
currentPage: number;
|
|
||||||
hasNext: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KlipyResponse = {
|
|
||||||
data?: {
|
|
||||||
data?: KlipyItem[];
|
|
||||||
current_page?: number;
|
|
||||||
has_next?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type PickerMedia = {
|
type PickerMedia = {
|
||||||
key: React.Key;
|
key: React.Key;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -105,7 +95,11 @@ async function fetchKlipyPage({
|
||||||
kind: KlipyKind;
|
kind: KlipyKind;
|
||||||
page: number;
|
page: number;
|
||||||
search: string;
|
search: string;
|
||||||
}): Promise<KlipyPage> {
|
}): Promise<{
|
||||||
|
items: KlipyItem[];
|
||||||
|
currentPage: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
}> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: String(page),
|
page: String(page),
|
||||||
per_page: String(pageSize),
|
per_page: String(pageSize),
|
||||||
|
|
@ -128,7 +122,13 @@ async function fetchKlipyPage({
|
||||||
throw new Error(`Klipy request failed with status ${response.status}`);
|
throw new Error(`Klipy request failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await response.json()) as KlipyResponse;
|
const body = (await response.json()) as {
|
||||||
|
data?: {
|
||||||
|
data?: KlipyItem[];
|
||||||
|
current_page?: number;
|
||||||
|
has_next?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
const data = body.data;
|
const data = body.data;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import Input from "@tensamin/markdown/input";
|
import Input, { type InputController } from "@tensamin/markdown/input";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
|
|
@ -7,10 +7,10 @@ import {
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
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 { 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 { useChat, useReplyMessage } from "../context";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
|
|
@ -22,13 +22,17 @@ import GifPicker from "./gifPicker";
|
||||||
import EmojiPicker from "./emojiPicker";
|
import EmojiPicker from "./emojiPicker";
|
||||||
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
||||||
import ReplyBox from "./replyBox";
|
import ReplyBox from "./replyBox";
|
||||||
|
import { useHotkey } from "@tensamin/hotkeys";
|
||||||
|
import { editLastMessageHotkey } from "../hotkeys";
|
||||||
|
|
||||||
export default function InputComponent({
|
export default function InputComponent({
|
||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
|
onEditLastMessage,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
|
onEditLastMessage: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [invertEnterBehavior, setInvertEnterBehavior] = useState(false);
|
const [invertEnterBehavior, setInvertEnterBehavior] = useState(false);
|
||||||
|
|
||||||
|
|
@ -52,6 +56,65 @@ export default function InputComponent({
|
||||||
width: number;
|
width: number;
|
||||||
height: 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(() => {
|
useEffect(() => {
|
||||||
void load("settings.reverse_enter_behavior").then((shouldInvert) => {
|
void load("settings.reverse_enter_behavior").then((shouldInvert) => {
|
||||||
|
|
@ -195,6 +258,7 @@ export default function InputComponent({
|
||||||
{/* reply */}
|
{/* reply */}
|
||||||
{replyTo !== undefined && (
|
{replyTo !== undefined && (
|
||||||
<ReplyBox
|
<ReplyBox
|
||||||
|
edited={replyMessage?.Edited}
|
||||||
content={replyMessage?.Content}
|
content={replyMessage?.Content}
|
||||||
loading={!replyMessage}
|
loading={!replyMessage}
|
||||||
onDismiss={() => setReplyTo(undefined)}
|
onDismiss={() => setReplyTo(undefined)}
|
||||||
|
|
@ -210,18 +274,27 @@ export default function InputComponent({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="relative p-0 flex flex-col">
|
<CardHeader className="relative p-0 flex flex-col">
|
||||||
<Input
|
<div className="w-full flex items-center">
|
||||||
className="w-full"
|
<Input
|
||||||
paddingY="13px"
|
className="w-full"
|
||||||
paddingX="13px"
|
onControllerChange={setComposer}
|
||||||
placeholder="Send a message..."
|
paddingY="13px"
|
||||||
value={value}
|
paddingX="13px"
|
||||||
setValue={setValue}
|
placeholder="Send a message..."
|
||||||
onSubmit={handleSubmit}
|
value={value}
|
||||||
invertEnterBehavior={invertEnterBehavior}
|
setValue={setValue}
|
||||||
emojiFrequencies={emojiFrequencies}
|
onSubmit={handleSubmit}
|
||||||
onEmojiSelect={recordUse}
|
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="w-full flex justify-between gap-1 p-1 pt-0">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button className="w-9 h-9! p-0" variant="ghost">
|
<Button className="w-9 h-9! p-0" variant="ghost">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { RawMessage } from "../values";
|
import type { RawMessage } from "../values";
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
|
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 type { User } from "@tensamin/user/context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,17 +24,23 @@ import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||||
import { useRecordEmojiUse } from "./emojiRanks";
|
import { useRecordEmojiUse } from "./emojiRanks";
|
||||||
import { useUser } from "@tensamin/user/context";
|
import { useUser } from "@tensamin/user/context";
|
||||||
import ReplyBox from "./replyBox";
|
import ReplyBox from "./replyBox";
|
||||||
|
import { useHotkey } from "@tensamin/hotkeys";
|
||||||
|
import { cancelMessageEditHotkey } from "../hotkeys";
|
||||||
|
|
||||||
function MessageComponent({
|
function MessageComponent({
|
||||||
|
editing,
|
||||||
grouped,
|
grouped,
|
||||||
message,
|
message,
|
||||||
|
onSetEditing,
|
||||||
user,
|
user,
|
||||||
}: {
|
}: {
|
||||||
|
editing: boolean;
|
||||||
grouped: boolean;
|
grouped: boolean;
|
||||||
message: RawMessage & {
|
message: RawMessage & {
|
||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
decryptionFailed?: boolean;
|
decryptionFailed?: boolean;
|
||||||
};
|
};
|
||||||
|
onSetEditing: (editing: boolean) => void;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
}) {
|
}) {
|
||||||
const actuallyFailed =
|
const actuallyFailed =
|
||||||
|
|
@ -48,6 +54,7 @@ function MessageComponent({
|
||||||
: message.MessageState === "awaiting"
|
: message.MessageState === "awaiting"
|
||||||
? "opacity-50"
|
? "opacity-50"
|
||||||
: "opacity-100";
|
: "opacity-100";
|
||||||
|
const messageRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
setHasFadedIn(true);
|
setHasFadedIn(true);
|
||||||
|
|
@ -141,7 +148,6 @@ function MessageComponent({
|
||||||
chatSecret,
|
chatSecret,
|
||||||
editMessage,
|
editMessage,
|
||||||
userId,
|
userId,
|
||||||
deleteMessage,
|
|
||||||
removeReaction,
|
removeReaction,
|
||||||
replyTo,
|
replyTo,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
@ -183,8 +189,27 @@ function MessageComponent({
|
||||||
};
|
};
|
||||||
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
||||||
const recordUse = useRecordEmojiUse();
|
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 [editDraft, setEditDraft] = useState(message.Content);
|
||||||
|
const cancelEditing = useCallback(() => {
|
||||||
|
setEditDraft(message.Content);
|
||||||
|
onSetEditing(false);
|
||||||
|
}, [message.Content, onSetEditing]);
|
||||||
|
useHotkey(cancelMessageEditHotkey, cancelEditing, {
|
||||||
|
enabled: editing,
|
||||||
|
target: messageRef,
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
setEditDraft(message.Content);
|
setEditDraft(message.Content);
|
||||||
|
|
@ -271,12 +296,14 @@ function MessageComponent({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={messageRef}
|
||||||
// pt-3 is to get a gap between messages
|
// 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}`}
|
className={`${grouped ? "" : "pt-3"} w-full flex flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
|
||||||
>
|
>
|
||||||
{/* reply */}
|
{/* reply */}
|
||||||
{replyMessage && replyUser && (
|
{replyMessage && replyUser && (
|
||||||
<ReplyBox
|
<ReplyBox
|
||||||
|
edited={replyMessage.Edited}
|
||||||
content={replyMessage.Content}
|
content={replyMessage.Content}
|
||||||
user={replyUser}
|
user={replyUser}
|
||||||
variant="message"
|
variant="message"
|
||||||
|
|
@ -289,7 +316,7 @@ function MessageComponent({
|
||||||
isOwnMessage={message.SenderId === ownId}
|
isOwnMessage={message.SenderId === ownId}
|
||||||
messageId={message.SendTime}
|
messageId={message.SendTime}
|
||||||
onReact={toggleReaction}
|
onReact={toggleReaction}
|
||||||
onSetEditing={setEditing}
|
onSetEditing={onSetEditing}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|
@ -305,7 +332,7 @@ function MessageComponent({
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{grouped ? (
|
{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([], {
|
{new Date(message.SendTime).toLocaleString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
|
@ -362,13 +389,17 @@ function MessageComponent({
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<Input
|
<Input
|
||||||
|
autoFocus
|
||||||
className="w-full"
|
className="w-full"
|
||||||
styled
|
styled
|
||||||
setValue={setEditDraft}
|
setValue={setEditDraft}
|
||||||
value={editDraft}
|
value={editDraft}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
submitEditMessage(editDraft);
|
if (!editDraft.trim()) return;
|
||||||
setEditing(false);
|
if (editDraft !== message.Content) {
|
||||||
|
void submitEditMessage(editDraft);
|
||||||
|
}
|
||||||
|
onSetEditing(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
@ -377,13 +408,12 @@ function MessageComponent({
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-primary-foreground-alt"
|
className="text-primary-foreground-alt"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (editDraft === message.Content) {
|
if (!editDraft.trim()) return;
|
||||||
deleteMessage(message.SendTime);
|
if (editDraft !== message.Content) {
|
||||||
} else {
|
void submitEditMessage(editDraft);
|
||||||
submitEditMessage(editDraft);
|
|
||||||
}
|
}
|
||||||
setEditDraft(message.Content);
|
setEditDraft(message.Content);
|
||||||
setEditing(false);
|
onSetEditing(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
|
|
@ -392,19 +422,25 @@ function MessageComponent({
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onClick={() => {
|
onClick={cancelEditing}
|
||||||
setEditDraft(message.Content);
|
|
||||||
setEditing(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{groupedReactions.length > 0 && (
|
||||||
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
||||||
|
|
@ -445,6 +481,7 @@ function MessageComponent({
|
||||||
export default memo(MessageComponent, (prev, next) => {
|
export default memo(MessageComponent, (prev, next) => {
|
||||||
return (
|
return (
|
||||||
prev.message.SendTime === next.message.SendTime &&
|
prev.message.SendTime === next.message.SendTime &&
|
||||||
|
prev.editing === next.editing &&
|
||||||
prev.message.Content === next.message.Content &&
|
prev.message.Content === next.message.Content &&
|
||||||
prev.message.SenderId === next.message.SenderId &&
|
prev.message.SenderId === next.message.SenderId &&
|
||||||
prev.message.ReplyId === next.message.ReplyId &&
|
prev.message.ReplyId === next.message.ReplyId &&
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ function ReplyUser({ user }: { user: User }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReplyBox({
|
export default function ReplyBox({
|
||||||
|
edited,
|
||||||
content,
|
content,
|
||||||
loading = false,
|
loading = false,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
|
@ -35,6 +36,7 @@ export default function ReplyBox({
|
||||||
userId,
|
userId,
|
||||||
variant,
|
variant,
|
||||||
}: {
|
}: {
|
||||||
|
edited?: boolean;
|
||||||
content?: string;
|
content?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
|
@ -74,8 +76,13 @@ export default function ReplyBox({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{content !== undefined && (
|
{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} />
|
<Text fontSize="0.88rem" value={content} />
|
||||||
|
{edited && (
|
||||||
|
<p className="text-xs text-muted-foreground self-center">
|
||||||
|
(edited)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import { useUser } from "@tensamin/user/context";
|
import { useUser } from "@tensamin/user/context";
|
||||||
import { createCache } from "@tensamin/cache";
|
import { createCache, type ChatDraft } from "@tensamin/cache";
|
||||||
import { secureValueCodec } from "@tensamin/storage/secure";
|
import { secureValueCodec } from "@tensamin/storage/secure";
|
||||||
|
|
||||||
export const context = createContext<contextType | undefined>(undefined);
|
export const context = createContext<contextType | undefined>(undefined);
|
||||||
|
|
@ -145,7 +145,16 @@ type SendMessageGet = (
|
||||||
data: { SendTime: number },
|
data: { SendTime: number },
|
||||||
) => Promise<{ data: RawMessage }>;
|
) => 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({
|
export async function getMessage({
|
||||||
sendTime,
|
sendTime,
|
||||||
|
|
@ -193,7 +202,7 @@ export async function fetchReplyMessage({
|
||||||
ownId: number;
|
ownId: number;
|
||||||
chatUserId: number;
|
chatUserId: number;
|
||||||
send: SendMessageGet;
|
send: SendMessageGet;
|
||||||
getChatSecret: GetChatSecret;
|
getChatSecret: (userId: number) => Promise<Uint8Array | null>;
|
||||||
}) {
|
}) {
|
||||||
const message = await getMessage({
|
const message = await getMessage({
|
||||||
sendTime: replyTo,
|
sendTime: replyTo,
|
||||||
|
|
@ -226,6 +235,10 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
const [liveMessagesState, setLiveMessagesState] = useState<LiveMessage[]>([]);
|
||||||
const [ownId, setOwnId] = useState(0);
|
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<{
|
const [currentChatSecretState, setCurrentChatSecretState] = useState<{
|
||||||
userId: number;
|
userId: number;
|
||||||
value: Uint8Array | null;
|
value: Uint8Array | null;
|
||||||
|
|
@ -258,6 +271,142 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
load("user_id").then(setOwnId);
|
load("user_id").then(setOwnId);
|
||||||
}, [load]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!userIdValue) return;
|
if (!userIdValue) return;
|
||||||
|
|
||||||
|
|
@ -907,12 +1056,6 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
userIdValue,
|
userIdValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Replys
|
|
||||||
const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
setReplyTo(undefined);
|
|
||||||
}, [userIdValue]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<context.Provider
|
<context.Provider
|
||||||
|
|
@ -927,10 +1070,13 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
removeReaction,
|
removeReaction,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
chatSecret: currentChatSecret,
|
chatSecret: currentChatSecret,
|
||||||
|
ownId,
|
||||||
userId: userIdValue,
|
userId: userIdValue,
|
||||||
inputBoxRef,
|
inputBoxRef,
|
||||||
error,
|
error,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
|
composerValue,
|
||||||
|
setComposerValue,
|
||||||
replyTo,
|
replyTo,
|
||||||
setReplyTo,
|
setReplyTo,
|
||||||
}}
|
}}
|
||||||
|
|
@ -954,10 +1100,13 @@ type contextType = {
|
||||||
removeReaction: (sendTime: number, reaction: string) => Promise<void>;
|
removeReaction: (sendTime: number, reaction: string) => Promise<void>;
|
||||||
clearLiveMessages: () => void;
|
clearLiveMessages: () => void;
|
||||||
chatSecret: Uint8Array | null;
|
chatSecret: Uint8Array | null;
|
||||||
|
ownId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
inputBoxRef: React.RefObject<HTMLDivElement | null>;
|
||||||
error: string;
|
error: string;
|
||||||
errorDescription: string;
|
errorDescription: string;
|
||||||
|
composerValue: string;
|
||||||
|
setComposerValue: (value: string) => void;
|
||||||
replyTo: number | undefined;
|
replyTo: number | undefined;
|
||||||
setReplyTo: (value: number | undefined) => void;
|
setReplyTo: (value: number | undefined) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
17
packages/chat/src/hotkeys.ts
Normal file
17
packages/chat/src/hotkeys.ts
Normal 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",
|
||||||
|
});
|
||||||
|
|
@ -21,12 +21,6 @@ import {
|
||||||
} from "./values";
|
} from "./values";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
|
|
||||||
type MessageChunk = {
|
|
||||||
key: string;
|
|
||||||
messages: Array<RawMessage | LiveMessage>;
|
|
||||||
startIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function shouldFetchPreviousPage({
|
function shouldFetchPreviousPage({
|
||||||
entry,
|
entry,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
|
|
@ -50,12 +44,69 @@ function getMessageRenderKey(message: RawMessage | LiveMessage) {
|
||||||
return "localId" in message ? message.localId : String(message.SendTime);
|
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(
|
function buildMessageChunks(
|
||||||
messages: Array<RawMessage | LiveMessage>,
|
messages: Array<RawMessage | LiveMessage>,
|
||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
startOffset = 0,
|
startOffset = 0,
|
||||||
) {
|
) {
|
||||||
const chunks: MessageChunk[] = [];
|
const chunks: {
|
||||||
|
key: string;
|
||||||
|
messages: Array<RawMessage | LiveMessage>;
|
||||||
|
startIndex: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) {
|
for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) {
|
||||||
const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW);
|
const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW);
|
||||||
|
|
@ -85,10 +136,15 @@ export default function Screen() {
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
userId,
|
userId,
|
||||||
chatSecret,
|
chatSecret,
|
||||||
|
ownId,
|
||||||
|
inputBoxRef,
|
||||||
error,
|
error,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
|
composerValue,
|
||||||
|
setComposerValue,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const composerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const didInitialScrollRef = useRef(false);
|
const didInitialScrollRef = useRef(false);
|
||||||
const userScrolledUpRef = useRef(false);
|
const userScrolledUpRef = useRef(false);
|
||||||
|
|
@ -102,11 +158,26 @@ export default function Screen() {
|
||||||
const [lastLiveMessageCount, setLastLiveMessageCount] = useState(0);
|
const [lastLiveMessageCount, setLastLiveMessageCount] = useState(0);
|
||||||
const [didInitialScroll, setDidInitialScroll] = useState(false);
|
const [didInitialScroll, setDidInitialScroll] = useState(false);
|
||||||
const [viewportHeight, setViewportHeight] = useState(0);
|
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 hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||||
const hasChatSecret = chatSecret !== null;
|
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({
|
const messagesQuery = useInfiniteQuery({
|
||||||
queryKey: ["chat-messages", String(userId), hasChatSecret],
|
queryKey: ["chat-messages", String(userId), hasChatSecret],
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
|
|
@ -141,6 +212,7 @@ export default function Screen() {
|
||||||
isAtBottomRef.current = true;
|
isAtBottomRef.current = true;
|
||||||
setDidInitialScroll(false);
|
setDidInitialScroll(false);
|
||||||
setLastLiveMessageCount(0);
|
setLastLiveMessageCount(0);
|
||||||
|
setEditingMessageId(null);
|
||||||
}, [clearLiveMessages, userId]);
|
}, [clearLiveMessages, userId]);
|
||||||
|
|
||||||
const historicalMessages = useMemo(() => {
|
const historicalMessages = useMemo(() => {
|
||||||
|
|
@ -208,11 +280,40 @@ export default function Screen() {
|
||||||
getItemKey,
|
getItemKey,
|
||||||
estimateSize,
|
estimateSize,
|
||||||
overscan: 2,
|
overscan: 2,
|
||||||
|
paddingStart: composerHeight + 20,
|
||||||
});
|
});
|
||||||
const totalSize = virtualizer.getTotalSize();
|
const totalSize = virtualizer.getTotalSize();
|
||||||
const contentHeight = Math.max(totalSize, viewportHeight);
|
const contentHeight = Math.max(totalSize, viewportHeight);
|
||||||
const verticalOffset = Math.max(0, viewportHeight - totalSize);
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
const element = scrollRef.current;
|
const element = scrollRef.current;
|
||||||
if (!element || typeof ResizeObserver === "undefined") {
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (didInitialScrollRef.current || virtualRowCount === 0) {
|
if (didInitialScrollRef.current || virtualRowCount === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -282,22 +403,6 @@ export default function Screen() {
|
||||||
void messagesQuery.fetchNextPage();
|
void messagesQuery.fetchNextPage();
|
||||||
}, [didInitialScroll, messagesQuery, totalSize, viewportHeight]);
|
}, [didInitialScroll, messagesQuery, totalSize, viewportHeight]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (
|
|
||||||
!didInitialScroll ||
|
|
||||||
userScrolledUpRef.current ||
|
|
||||||
virtualRowCount === 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [didInitialScroll, virtualRowCount]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = scrollRef.current;
|
const root = scrollRef.current;
|
||||||
const sentinel = topSentinelRef.current;
|
const sentinel = topSentinelRef.current;
|
||||||
|
|
@ -436,7 +541,7 @@ export default function Screen() {
|
||||||
className="min-h-0 flex-1 overflow-y-auto"
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
style={{
|
style={{
|
||||||
overflowAnchor: "none",
|
overflowAnchor: "none",
|
||||||
paddingTop: "22px",
|
//paddingTop: "22px",
|
||||||
transform: "scaleY(-1)",
|
transform: "scaleY(-1)",
|
||||||
}}
|
}}
|
||||||
onScroll={handleContainerScroll}
|
onScroll={handleContainerScroll}
|
||||||
|
|
@ -475,8 +580,15 @@ export default function Screen() {
|
||||||
const messageIndex =
|
const messageIndex =
|
||||||
chunk.startIndex + chunkMessageIndex;
|
chunk.startIndex + chunkMessageIndex;
|
||||||
const lastMessage = messages[messageIndex - 1];
|
const lastMessage = messages[messageIndex - 1];
|
||||||
|
const startsNewDay =
|
||||||
|
!lastMessage ||
|
||||||
|
!isSameDay(lastMessage.SendTime, message.SendTime);
|
||||||
|
const dateLabel = startsNewDay
|
||||||
|
? formatMessageDate(message.SendTime)
|
||||||
|
: null;
|
||||||
const isGrouped =
|
const isGrouped =
|
||||||
lastMessage &&
|
lastMessage &&
|
||||||
|
!startsNewDay &&
|
||||||
!message.ReplyId &&
|
!message.ReplyId &&
|
||||||
lastMessage.SenderId === message.SenderId &&
|
lastMessage.SenderId === message.SenderId &&
|
||||||
Math.round(lastMessage.SendTime / 10000) ===
|
Math.round(lastMessage.SendTime / 10000) ===
|
||||||
|
|
@ -488,11 +600,24 @@ export default function Screen() {
|
||||||
userId={message.SenderId}
|
userId={message.SenderId}
|
||||||
loading={null}
|
loading={null}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<Message
|
<>
|
||||||
grouped={isGrouped}
|
{dateLabel && (
|
||||||
message={message}
|
<DateSeparator label={dateLabel} />
|
||||||
user={user}
|
)}
|
||||||
/>
|
<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>
|
</div>
|
||||||
<div className="z-10 shrink-0">
|
<div ref={composerRef} className="absolute inset-x-0 bottom-0 z-10">
|
||||||
<InputComponent setValue={setValue} value={value} />
|
<InputComponent
|
||||||
|
onEditLastMessage={editLastMessage}
|
||||||
|
setValue={setComposerValue}
|
||||||
|
value={composerValue}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
- Implement context menu features
|
- Implement context menu features
|
||||||
- Forward
|
- Forward
|
||||||
- Pin Message
|
- Pin Message
|
||||||
- Reply
|
|
||||||
- Add default-emoji-hotkey
|
|
||||||
- Placeholder image if media fails to load
|
- Placeholder image if media fails to load
|
||||||
- Signature verifications via ed25519 key
|
- Signature verifications via ed25519 key
|
||||||
- Confirmation when exiting with text in the input box.
|
- 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
|
- Drop any unique reactions above 10
|
||||||
- Reply jumping
|
- Reply jumping
|
||||||
- Add emoji picker
|
- Add emoji picker
|
||||||
|
|
|
||||||
24
packages/hotkeys/package.json
Normal file
24
packages/hotkeys/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
252
packages/hotkeys/src/context.tsx
Normal file
252
packages/hotkeys/src/context.tsx
Normal 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]);
|
||||||
|
}
|
||||||
14
packages/hotkeys/src/index.ts
Normal file
14
packages/hotkeys/src/index.ts
Normal 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";
|
||||||
94
packages/hotkeys/src/registry.ts
Normal file
94
packages/hotkeys/src/registry.ts
Normal 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("+");
|
||||||
|
}
|
||||||
13
packages/hotkeys/tsconfig.json
Normal file
13
packages/hotkeys/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
|
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
|
||||||
|
|
||||||
type ShortcodeValue = string | string[];
|
|
||||||
|
|
||||||
export type EmojiDefinition = {
|
export type EmojiDefinition = {
|
||||||
aliases: readonly string[];
|
aliases: readonly string[];
|
||||||
hexcode: string;
|
hexcode: string;
|
||||||
|
|
@ -17,7 +15,7 @@ function normalizeName(value: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emojis: readonly EmojiDefinition[] = Object.entries(
|
export const emojis: readonly EmojiDefinition[] = Object.entries(
|
||||||
shortcodeData as Record<string, ShortcodeValue>,
|
shortcodeData as Record<string, string | string[]>,
|
||||||
).map(([hexcode, value]) => {
|
).map(([hexcode, value]) => {
|
||||||
const aliases = Array.isArray(value) ? value : [value];
|
const aliases = Array.isArray(value) ? value : [value];
|
||||||
const name = aliases[0];
|
const name = aliases[0];
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ import Emoji, {
|
||||||
|
|
||||||
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
||||||
|
|
||||||
|
export type InputController = {
|
||||||
|
focus: () => void;
|
||||||
|
hasFocus: () => boolean;
|
||||||
|
insertText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type InputProps = {
|
export type InputProps = {
|
||||||
ref?: HTMLDivElement;
|
ref?: HTMLDivElement;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
@ -66,10 +72,8 @@ export type InputProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
emojiFrequencies?: Readonly<Record<string, number>>;
|
emojiFrequencies?: Readonly<Record<string, number>>;
|
||||||
onEmojiSelect?: (shortcode: string) => void;
|
onEmojiSelect?: (shortcode: string) => void;
|
||||||
};
|
autoFocus?: boolean;
|
||||||
|
onControllerChange?: (controller: InputController | null) => void;
|
||||||
type InputStyle = CSSProperties & {
|
|
||||||
"--tm-md-content-padding"?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function toCssLength(value: CSSProperties["padding"]): string | undefined {
|
function toCssLength(value: CSSProperties["padding"]): string | undefined {
|
||||||
|
|
@ -91,11 +95,6 @@ function toCssPadding(
|
||||||
return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`;
|
return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenRange = {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" });
|
const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" });
|
||||||
const strongDecoration = Decoration.mark({ class: "tm-md-strong" });
|
const strongDecoration = Decoration.mark({ class: "tm-md-strong" });
|
||||||
const emDecoration = Decoration.mark({ class: "tm-md-em" });
|
const emDecoration = Decoration.mark({ class: "tm-md-em" });
|
||||||
|
|
@ -316,6 +315,7 @@ export default function Input(props: InputProps) {
|
||||||
|
|
||||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<EditorView | undefined>(undefined);
|
const viewRef = useRef<EditorView | undefined>(undefined);
|
||||||
|
const setValueRef = useRef(props.setValue);
|
||||||
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
|
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
|
||||||
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
|
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
|
||||||
props.onEmojiSelect,
|
props.onEmojiSelect,
|
||||||
|
|
@ -326,10 +326,16 @@ export default function Input(props: InputProps) {
|
||||||
const completionCompartment = completionCompartmentRef.current;
|
const completionCompartment = completionCompartmentRef.current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setValueRef.current = props.setValue;
|
||||||
onSubmitRef.current = props.onSubmit;
|
onSubmitRef.current = props.onSubmit;
|
||||||
onEmojiSelectRef.current = props.onEmojiSelect;
|
onEmojiSelectRef.current = props.onEmojiSelect;
|
||||||
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
|
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
|
||||||
}, [props.onEmojiSelect, props.onSubmit, props.invertEnterBehavior]);
|
}, [
|
||||||
|
props.onEmojiSelect,
|
||||||
|
props.onSubmit,
|
||||||
|
props.invertEnterBehavior,
|
||||||
|
props.setValue,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!elementRef.current) return;
|
if (!elementRef.current) return;
|
||||||
|
|
@ -338,7 +344,7 @@ export default function Input(props: InputProps) {
|
||||||
doc: props.value,
|
doc: props.value,
|
||||||
extensions: createEditorExtensions(
|
extensions: createEditorExtensions(
|
||||||
(value) => {
|
(value) => {
|
||||||
props.setValue(value);
|
setValueRef.current(value);
|
||||||
},
|
},
|
||||||
() => props.placeholder,
|
() => props.placeholder,
|
||||||
() => invertEnterBehaviorRef.current,
|
() => invertEnterBehaviorRef.current,
|
||||||
|
|
@ -353,8 +359,23 @@ export default function Input(props: InputProps) {
|
||||||
state,
|
state,
|
||||||
parent: elementRef.current,
|
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 () => {
|
return () => {
|
||||||
|
props.onControllerChange?.(null);
|
||||||
viewRef.current?.destroy();
|
viewRef.current?.destroy();
|
||||||
viewRef.current = undefined;
|
viewRef.current = undefined;
|
||||||
};
|
};
|
||||||
|
|
@ -413,7 +434,9 @@ export default function Input(props: InputProps) {
|
||||||
props.paddingX,
|
props.paddingX,
|
||||||
Boolean(props.styled),
|
Boolean(props.styled),
|
||||||
),
|
),
|
||||||
} as InputStyle
|
} as CSSProperties & {
|
||||||
|
"--tm-md-content-padding"?: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -804,7 +827,10 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||||
function addHiddenToken(
|
function addHiddenToken(
|
||||||
builder: Range<Decoration>[],
|
builder: Range<Decoration>[],
|
||||||
selections: ReadonlyArray<{ from: number; to: number }>,
|
selections: ReadonlyArray<{ from: number; to: number }>,
|
||||||
token: TokenRange,
|
token: {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
},
|
||||||
): void {
|
): void {
|
||||||
if (token.from >= token.to) return;
|
if (token.from >= token.to) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import { Fragment, type ReactElement, type ReactNode } from "react";
|
import {
|
||||||
|
Fragment,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import Emoji from "./emoji";
|
import Emoji from "./emoji";
|
||||||
import { findEmojiShortcodes } from "./emojiData";
|
import { findEmojiShortcodes } from "./emojiData";
|
||||||
|
|
||||||
|
|
@ -23,43 +30,11 @@ type InlineTokenRange = {
|
||||||
to: number;
|
to: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParagraphBlock = {
|
|
||||||
type: "paragraph";
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HeadingBlock = {
|
|
||||||
type: "heading";
|
|
||||||
level: number;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HrBlock = {
|
|
||||||
type: "hr";
|
|
||||||
};
|
|
||||||
|
|
||||||
type BlockQuoteBlock = {
|
|
||||||
type: "blockquote";
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CodeBlock = {
|
|
||||||
type: "code";
|
|
||||||
language: string;
|
|
||||||
code: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ListItem = {
|
type ListItem = {
|
||||||
text: string;
|
text: string;
|
||||||
checked: boolean | null;
|
checked: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ListBlock = {
|
|
||||||
type: "list";
|
|
||||||
ordered: boolean;
|
|
||||||
items: ListItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type TableBlock = {
|
type TableBlock = {
|
||||||
type: "table";
|
type: "table";
|
||||||
headers: string[];
|
headers: string[];
|
||||||
|
|
@ -67,17 +42,140 @@ type TableBlock = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type MarkdownBlock =
|
type MarkdownBlock =
|
||||||
| ParagraphBlock
|
| {
|
||||||
| HeadingBlock
|
type: "paragraph";
|
||||||
| HrBlock
|
text: string;
|
||||||
| BlockQuoteBlock
|
}
|
||||||
| CodeBlock
|
| {
|
||||||
| ListBlock
|
type: "heading";
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "hr";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "blockquote";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "code";
|
||||||
|
language: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "list";
|
||||||
|
ordered: boolean;
|
||||||
|
items: ListItem[];
|
||||||
|
}
|
||||||
| TableBlock;
|
| TableBlock;
|
||||||
|
|
||||||
const INLINE_TOKEN_REGEX =
|
const INLINE_TOKEN_REGEX =
|
||||||
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
|
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
|
||||||
|
|
||||||
|
function CopiedIndicator({
|
||||||
|
block,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
block: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-flex align-middle text-foreground transition-opacity duration-200 ease-out ${block ? "mt-3 shrink-0" : "ml-1"} ${visible ? "opacity-100" : "opacity-0"}`}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-hidden={!visible}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="size-3.5"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="3.5"
|
||||||
|
width="10"
|
||||||
|
height="11"
|
||||||
|
rx="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6 4V2.75C6 2.06 6.56 1.5 7.25 1.5h1.5c.69 0 1.25.56 1.25 1.25V4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only">Copied</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyableCode({
|
||||||
|
block = false,
|
||||||
|
language,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
block?: boolean;
|
||||||
|
language?: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearTimeout(copiedTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
clearTimeout(copiedTimer.current);
|
||||||
|
copiedTimer.current = setTimeout(() => setCopied(false), 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (
|
||||||
|
<code
|
||||||
|
className={block ? "tm-md-codeblock" : "tm-md-code"}
|
||||||
|
data-language={language}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => void copy()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
void copy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (block) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-start gap-1">
|
||||||
|
<pre className="tm-md-pre min-w-0 flex-1">{code}</pre>
|
||||||
|
<CopiedIndicator block visible={copied} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{code}
|
||||||
|
<CopiedIndicator block={false} visible={copied} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes parseInlineNodes.
|
* Executes parseInlineNodes.
|
||||||
* @param input Parameter input.
|
* @param input Parameter input.
|
||||||
|
|
@ -429,11 +527,7 @@ function renderInline(nodes: InlineNode[]): ReactNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === "code") {
|
if (node.type === "code") {
|
||||||
return (
|
return <CopyableCode key={index} value={node.value} />;
|
||||||
<code key={index} className="tm-md-code">
|
|
||||||
{node.value}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === "link") {
|
if (node.type === "link") {
|
||||||
|
|
@ -523,11 +617,12 @@ export function renderBlocks(blocks: MarkdownBlock[]): ReactElement {
|
||||||
|
|
||||||
if (block.type === "code") {
|
if (block.type === "code") {
|
||||||
return (
|
return (
|
||||||
<pre key={blockIndex} className="tm-md-pre">
|
<CopyableCode
|
||||||
<code className="tm-md-codeblock" data-language={block.language}>
|
key={blockIndex}
|
||||||
{block.code}
|
block
|
||||||
</code>
|
language={block.language}
|
||||||
</pre>
|
value={block.code}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -666,7 +761,7 @@ function readTable(
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownStyles = `
|
const markdownStyles = `
|
||||||
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.65; font-size: 1rem; }
|
.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; }
|
||||||
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
|
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
|
||||||
.tm-md-h1 { font-size: 1.65rem; }
|
.tm-md-h1 { font-size: 1.65rem; }
|
||||||
.tm-md-h2 { font-size: 1.45rem; }
|
.tm-md-h2 { font-size: 1.45rem; }
|
||||||
|
|
@ -676,13 +771,15 @@ const markdownStyles = `
|
||||||
.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; }
|
.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; }
|
||||||
.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; }
|
.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; }
|
||||||
.tm-md-blockquote p { margin: 0.2rem 0; }
|
.tm-md-blockquote p { margin: 0.2rem 0; }
|
||||||
.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: hsl(var(--muted)); overflow-x: auto; }
|
.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: var(--muted); overflow-x: auto; }
|
||||||
.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; }
|
||||||
.tm-md-code { padding: 0.08rem 0.32rem; border-radius: 0.28rem; background: hsl(var(--muted)); }
|
.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); }
|
||||||
|
.tm-md-codeblock { display: block; }
|
||||||
|
.tm-md-code:focus-visible, .tm-md-codeblock:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||||
.tm-md-strong { font-weight: 700; }
|
.tm-md-strong { font-weight: 700; }
|
||||||
.tm-md-em { font-style: italic; }
|
.tm-md-em { font-style: italic; }
|
||||||
.tm-md-del { text-decoration: line-through; }
|
.tm-md-del { text-decoration: line-through; }
|
||||||
.tm-md-link { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 0.14rem; }
|
.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; }
|
||||||
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
|
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
|
||||||
.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; }
|
.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; }
|
||||||
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
|
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
|
||||||
|
|
@ -691,7 +788,7 @@ const markdownStyles = `
|
||||||
.tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; }
|
.tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; }
|
||||||
.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; }
|
.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; }
|
||||||
.tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; }
|
.tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; }
|
||||||
.tm-md-table th { background: hsl(var(--muted)); font-weight: 600; }
|
.tm-md-table th { background: var(--muted); font-weight: 600; }
|
||||||
.tm-md-hr { margin: 0.55rem 0; }
|
.tm-md-hr { margin: 0.55rem 0; }
|
||||||
|
|
||||||
.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); }
|
.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); }
|
||||||
|
|
@ -699,10 +796,10 @@ const markdownStyles = `
|
||||||
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
|
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
|
||||||
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
||||||
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
|
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
|
||||||
.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
|
.cm-editor.tm-md-editor .cm-line { padding: 0; color: var(--foreground); }
|
||||||
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
|
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
|
||||||
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
||||||
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; }
|
||||||
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
|
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
|
||||||
.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; }
|
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
|
@ -726,10 +823,15 @@ export function ensureMarkdownStyles(): void {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
const styleId = "tensamin-markdown-styles";
|
const styleId = "tensamin-markdown-styles";
|
||||||
if (document.getElementById(styleId)) return;
|
let style = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||||
|
|
||||||
const style = document.createElement("style");
|
if (!style) {
|
||||||
style.id = styleId;
|
style = document.createElement("style");
|
||||||
style.textContent = markdownStyles;
|
style.id = styleId;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.textContent !== markdownStyles) {
|
||||||
|
style.textContent = markdownStyles;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
"mtp": "*",
|
"mtp": "*",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tauri-plugin-app-events-api": "^0.2.0",
|
|
||||||
"zod": "^4.4.2"
|
"zod": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
import { onResume } from "tauri-plugin-app-events-api";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { MTPClient } from "mtp";
|
import { MTPClient } from "mtp";
|
||||||
import { type z } from "zod";
|
import { type z } from "zod";
|
||||||
import { ConnectionState } from "mtp";
|
import { ConnectionState } from "mtp";
|
||||||
|
|
@ -92,10 +92,6 @@ type ContextType = {
|
||||||
|
|
||||||
const MTPContext = createContext<ContextType | undefined>(undefined);
|
const MTPContext = createContext<ContextType | undefined>(undefined);
|
||||||
|
|
||||||
function isTauriMobile() {
|
|
||||||
return isTauri() && /Android|iPhone|iPad|iPod/.test(navigator.userAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProtocolErrorDetails(error: unknown) {
|
function getProtocolErrorDetails(error: unknown) {
|
||||||
if (typeof error !== "object" || error === null || !("type" in error)) {
|
if (typeof error !== "object" || error === null || !("type" in error)) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -143,7 +139,21 @@ function validateResponse<T extends keyof Schemas & string>(
|
||||||
} as ProtocolMessage<T>;
|
} 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;
|
children: ReactNode;
|
||||||
blockConnection?: boolean;
|
blockConnection?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -162,8 +172,8 @@ export function Provider(props: {
|
||||||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
|
||||||
const pushHandlersRef = useRef(new Set<PushHandler>());
|
useMessageHandlers();
|
||||||
|
|
||||||
const connected = readyState === ConnectionState.Connected;
|
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
|
// Reconnect stuff
|
||||||
const resolveConnectionRef = useRef(() => {});
|
const resolveConnectionRef = useRef(() => {});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -223,7 +223,6 @@ export function Provider(props: {
|
||||||
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let reconnectScheduled = false;
|
let reconnectScheduled = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let resumeListenerRegistered = false;
|
|
||||||
let connectionGeneration = 0;
|
let connectionGeneration = 0;
|
||||||
|
|
||||||
const clearReconnectTimer = () => {
|
const clearReconnectTimer = () => {
|
||||||
|
|
@ -292,8 +291,6 @@ export function Provider(props: {
|
||||||
setIdentified(false);
|
setIdentified(false);
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
|
|
||||||
await MTPClient.init();
|
|
||||||
|
|
||||||
const [userId, keyring] = await Promise.all([
|
const [userId, keyring] = await Promise.all([
|
||||||
load("user_id"),
|
load("user_id"),
|
||||||
load("mtp_keyring"),
|
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();
|
void connect();
|
||||||
|
|
||||||
if (!props.blockConnection && isTauriMobile()) {
|
|
||||||
resumeListenerRegistered = true;
|
|
||||||
onResume(() => {
|
|
||||||
void reconnectAfterResume();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
clearReconnectResetTimer();
|
clearReconnectResetTimer();
|
||||||
|
|
||||||
if (resumeListenerRegistered) {
|
|
||||||
onResume();
|
|
||||||
}
|
|
||||||
|
|
||||||
clientRef.current?.disconnect();
|
clientRef.current?.disconnect();
|
||||||
clientRef.current = null;
|
clientRef.current = null;
|
||||||
setReadyState(ConnectionState.Disconnected);
|
setReadyState(ConnectionState.Disconnected);
|
||||||
|
|
@ -564,7 +537,7 @@ export function Provider(props: {
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
sonnerToast.dismiss("mtp-connection-toast");
|
sonnerToast.dismiss("mtp-connection-toast");
|
||||||
};
|
};
|
||||||
}, [mtpUrl, props.blockConnection, load]);
|
}, [mtpUrl, props.blockConnection, load, pushHandlersRef]);
|
||||||
|
|
||||||
// No Iota check
|
// No Iota check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -626,7 +599,7 @@ export function Provider(props: {
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
[mtpRef],
|
[interceptorsRef, mtpRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 {
|
export function useMTP(): ContextType {
|
||||||
const context = useContext(MTPContext);
|
const context = useContext(MTPContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useMTP } from "@tensamin/mtp";
|
||||||
import { createContext, useEffect, useContext } from "react";
|
import { createContext, useEffect, useContext } from "react";
|
||||||
import { toast as sonnerToast } from "sonner";
|
import { toast as sonnerToast } from "sonner";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@methanium/ui";
|
import { Avatar, AvatarFallback, AvatarImage } from "@methanium/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
isPermissionGranted as isTauriNotificationPermissionGranted,
|
isPermissionGranted as isTauriNotificationPermissionGranted,
|
||||||
requestPermission as requestTauriNotificationPermission,
|
requestPermission as requestTauriNotificationPermission,
|
||||||
|
|
@ -102,12 +102,36 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
const user = await get(data.SenderId);
|
const user = await get(data.SenderId);
|
||||||
|
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
|
if (!appFocused) return;
|
||||||
const permissionGranted =
|
const permissionGranted =
|
||||||
(await isTauriNotificationPermissionGranted()) ||
|
(await isTauriNotificationPermissionGranted()) ||
|
||||||
(await requestTauriNotificationPermission()) === "granted";
|
(await requestTauriNotificationPermission()) === "granted";
|
||||||
|
|
||||||
if (permissionGranted) {
|
if (permissionGranted) {
|
||||||
sendTauriNotification({ title: user.Display, body: content });
|
let handledNatively = false;
|
||||||
|
try {
|
||||||
|
handledNatively = await invoke<boolean>(
|
||||||
|
"mtp_post_message_notification",
|
||||||
|
{
|
||||||
|
senderId: user.UserId,
|
||||||
|
sender: user.Display,
|
||||||
|
body: content,
|
||||||
|
avatar: user.Avatar,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log(
|
||||||
|
1,
|
||||||
|
"notifications",
|
||||||
|
"red",
|
||||||
|
"Failed to create native message notification",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handledNatively) {
|
||||||
|
sendTauriNotification({ title: user.Display, body: content });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const hasPermissions = await requestNotificationPermission();
|
const hasPermissions = await requestNotificationPermission();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-notification": "~2",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
"@methanium/ui": "*",
|
"@methanium/ui": "*",
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ import {
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
import LegalPage from "./pages/legal";
|
import LegalPage from "./pages/legal";
|
||||||
import { onboardingSteps } from "./steps";
|
import { onboardingSteps } from "./steps";
|
||||||
|
import TauriPermissionsPage from "./pages/tauriPermissions";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useOnboardingStep,
|
useOnboardingStep,
|
||||||
|
|
@ -19,14 +21,15 @@ export {
|
||||||
type OnboardingStepControls,
|
type OnboardingStepControls,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
|
|
||||||
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
|
||||||
|
|
||||||
interface GateState {
|
interface GateState {
|
||||||
docs: LegalDocs;
|
docs: z.infer<typeof legalDocsSchema>;
|
||||||
acceptedPP: boolean;
|
acceptedPP: boolean;
|
||||||
acceptedTOS: boolean;
|
acceptedTOS: boolean;
|
||||||
includeLegal: boolean;
|
includeLegal: boolean;
|
||||||
includeOnboarding: boolean;
|
includeOnboarding: boolean;
|
||||||
|
includeTauriPermissions: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingGate({ children }: { children: ReactNode }) {
|
export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
|
|
@ -72,12 +75,14 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
acceptedTOS,
|
acceptedTOS,
|
||||||
onboardingDone,
|
onboardingDone,
|
||||||
onboardingStarted,
|
onboardingStarted,
|
||||||
|
tauriPermissionsDone,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
load("legal_docs"),
|
load("legal_docs"),
|
||||||
load("accepted_privacy_policy"),
|
load("accepted_privacy_policy"),
|
||||||
load("accepted_terms_of_service"),
|
load("accepted_terms_of_service"),
|
||||||
load("onboarding_done"),
|
load("onboarding_done"),
|
||||||
load("onboarding_started"),
|
load("onboarding_started"),
|
||||||
|
load("tauri_permissions_done"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
@ -104,6 +109,10 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
acceptedTOS: currentAcceptedTOS,
|
acceptedTOS: currentAcceptedTOS,
|
||||||
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
||||||
includeOnboarding,
|
includeOnboarding,
|
||||||
|
includeTauriPermissions:
|
||||||
|
isTauri() &&
|
||||||
|
/Android/.test(navigator.userAgent) &&
|
||||||
|
!tauriPermissionsDone,
|
||||||
});
|
});
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
@ -142,8 +151,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
save("onboarding_started", false),
|
save("onboarding_started", false),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
if (state?.includeTauriPermissions) {
|
||||||
|
await save("tauri_permissions_done", true);
|
||||||
|
}
|
||||||
setComplete(true);
|
setComplete(true);
|
||||||
}, [save, state?.includeOnboarding]);
|
}, [save, state?.includeOnboarding, state?.includeTauriPermissions]);
|
||||||
|
|
||||||
if (error && errorDescription) {
|
if (error && errorDescription) {
|
||||||
return <ErrorScreen error={error} description={errorDescription} />;
|
return <ErrorScreen error={error} description={errorDescription} />;
|
||||||
|
|
@ -174,6 +186,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
if (state.includeOnboarding) {
|
if (state.includeOnboarding) {
|
||||||
steps.push(...onboardingSteps(onboardingThemeId, setOnboardingThemeId));
|
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}</>;
|
if (complete || steps.length === 0) return <>{children}</>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { z } from "zod";
|
||||||
|
|
||||||
import { useOnboardingStep } from "@methanium/ui";
|
import { useOnboardingStep } from "@methanium/ui";
|
||||||
|
|
||||||
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
|
||||||
|
|
||||||
export default function LegalPage({
|
export default function LegalPage({
|
||||||
docs,
|
docs,
|
||||||
|
|
@ -13,7 +13,7 @@ export default function LegalPage({
|
||||||
initiallyAcceptedTOS,
|
initiallyAcceptedTOS,
|
||||||
onAccept,
|
onAccept,
|
||||||
}: {
|
}: {
|
||||||
docs: LegalDocs;
|
docs: z.infer<typeof legalDocsSchema>;
|
||||||
initiallyAcceptedPP: boolean;
|
initiallyAcceptedPP: boolean;
|
||||||
initiallyAcceptedTOS: boolean;
|
initiallyAcceptedTOS: boolean;
|
||||||
onAccept: () => Promise<void>;
|
onAccept: () => Promise<void>;
|
||||||
|
|
@ -32,7 +32,7 @@ export default function LegalPage({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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-1 items-center justify-center">
|
||||||
<div className="flex flex-col items-start gap-2">
|
<div className="flex flex-col items-start gap-2">
|
||||||
<BigCheckbox
|
<BigCheckbox
|
||||||
|
|
|
||||||
99
packages/onboarding/src/pages/tauriPermissions.tsx
Normal file
99
packages/onboarding/src/pages/tauriPermissions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-router": "^1.169.1",
|
"@tanstack/react-router": "^1.169.1",
|
||||||
"@tensamin/cache": "workspace:*",
|
"@tensamin/cache": "workspace:*",
|
||||||
|
"@tensamin/hotkeys": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@ import { storageDefaults, type Storage } from "@tensamin/shared/data";
|
||||||
import { settingsStorageDefaults } from "@tensamin/shared/settings";
|
import { settingsStorageDefaults } from "@tensamin/shared/settings";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
type BooleanStorageKey = {
|
|
||||||
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
|
|
||||||
}[keyof Storage];
|
|
||||||
|
|
||||||
type ListStorageKey = {
|
type ListStorageKey = {
|
||||||
[K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never;
|
[K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never;
|
||||||
|
|
@ -21,7 +19,9 @@ export function Switch({
|
||||||
id,
|
id,
|
||||||
}: {
|
}: {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
id: keyof typeof settingsStorageDefaults & ({
|
||||||
|
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
|
||||||
|
}[keyof Storage]);
|
||||||
}) {
|
}) {
|
||||||
const { save, load } = useStorage();
|
const { save, load } = useStorage();
|
||||||
const [value, setValue] = useState<boolean>(settingsStorageDefaults[id]);
|
const [value, setValue] = useState<boolean>(settingsStorageDefaults[id]);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Licenses from "./pages/licenses";
|
||||||
import Profile from "./pages/profile";
|
import Profile from "./pages/profile";
|
||||||
import Security from "./pages/security";
|
import Security from "./pages/security";
|
||||||
import Theme from "./pages/theme";
|
import Theme from "./pages/theme";
|
||||||
|
import Hotkeys from "./pages/hotkeys";
|
||||||
|
|
||||||
export const settingsPages = [
|
export const settingsPages = [
|
||||||
{ path: "/", component: Index },
|
{ path: "/", component: Index },
|
||||||
|
|
@ -25,6 +26,12 @@ export const settingsPages = [
|
||||||
{ category: "general", path: "call", label: "Call", component: Call },
|
{ category: "general", path: "call", label: "Call", component: Call },
|
||||||
{ category: "application", path: "cache", label: "Cache", component: Cache },
|
{ category: "application", path: "cache", label: "Cache", component: Cache },
|
||||||
{ category: "application", path: "theme", label: "Theme", component: Theme },
|
{ category: "application", path: "theme", label: "Theme", component: Theme },
|
||||||
|
{
|
||||||
|
category: "application",
|
||||||
|
path: "hotkeys",
|
||||||
|
label: "Hotkeys",
|
||||||
|
component: Hotkeys,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
category: "application",
|
category: "application",
|
||||||
path: "licenses",
|
path: "licenses",
|
||||||
|
|
|
||||||
152
packages/settings/src/pages/hotkeys.tsx
Normal file
152
packages/settings/src/pages/hotkeys.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -143,25 +143,7 @@ export type Contacts = z.infer<typeof authPayload.shape.Contacts>;
|
||||||
export type Communities = z.infer<typeof authPayload.shape.Communities>;
|
export type Communities = z.infer<typeof authPayload.shape.Communities>;
|
||||||
export type Calls = z.infer<typeof authPayload.shape.Calls>;
|
export type Calls = z.infer<typeof authPayload.shape.Calls>;
|
||||||
|
|
||||||
type Base16Palette = Record<
|
|
||||||
| "base00"
|
|
||||||
| "base01"
|
|
||||||
| "base02"
|
|
||||||
| "base03"
|
|
||||||
| "base04"
|
|
||||||
| "base05"
|
|
||||||
| "base06"
|
|
||||||
| "base07"
|
|
||||||
| "base08"
|
|
||||||
| "base09"
|
|
||||||
| "base0A"
|
|
||||||
| "base0B"
|
|
||||||
| "base0C"
|
|
||||||
| "base0D"
|
|
||||||
| "base0E"
|
|
||||||
| "base0F",
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
|
|
||||||
// MTP
|
// MTP
|
||||||
const user = z.object({
|
const user = z.object({
|
||||||
|
|
@ -454,6 +436,7 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
mtp_keyring: string;
|
mtp_keyring: string;
|
||||||
onboarding_done: boolean;
|
onboarding_done: boolean;
|
||||||
onboarding_started: boolean;
|
onboarding_started: boolean;
|
||||||
|
tauri_permissions_done: boolean;
|
||||||
ppandtos_done: boolean;
|
ppandtos_done: boolean;
|
||||||
accepted_terms_of_service: boolean;
|
accepted_terms_of_service: boolean;
|
||||||
accepted_privacy_policy: boolean;
|
accepted_privacy_policy: boolean;
|
||||||
|
|
@ -471,7 +454,25 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
call_mute_range_start: number;
|
call_mute_range_start: number;
|
||||||
call_mute_range_end: number;
|
call_mute_range_end: number;
|
||||||
theme_color: string;
|
theme_color: string;
|
||||||
theme_palette: Base16Palette | null;
|
theme_palette: Record<
|
||||||
|
| "base00"
|
||||||
|
| "base01"
|
||||||
|
| "base02"
|
||||||
|
| "base03"
|
||||||
|
| "base04"
|
||||||
|
| "base05"
|
||||||
|
| "base06"
|
||||||
|
| "base07"
|
||||||
|
| "base08"
|
||||||
|
| "base09"
|
||||||
|
| "base0A"
|
||||||
|
| "base0B"
|
||||||
|
| "base0C"
|
||||||
|
| "base0D"
|
||||||
|
| "base0E"
|
||||||
|
| "base0F",
|
||||||
|
string
|
||||||
|
> | null;
|
||||||
theme_primary_color: string;
|
theme_primary_color: string;
|
||||||
theme_polarity: "dark" | "light" | "system";
|
theme_polarity: "dark" | "light" | "system";
|
||||||
theme_tint: "soft" | "hard" | "extreme";
|
theme_tint: "soft" | "hard" | "extreme";
|
||||||
|
|
@ -487,6 +488,7 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
height: number;
|
height: number;
|
||||||
} | null;
|
} | null;
|
||||||
reactions: Record<string, number>;
|
reactions: Record<string, number>;
|
||||||
|
hotkey_overrides: Record<string, string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageDefaults: Storage = {
|
export const storageDefaults: Storage = {
|
||||||
|
|
@ -495,6 +497,7 @@ export const storageDefaults: Storage = {
|
||||||
mtp_keyring: "",
|
mtp_keyring: "",
|
||||||
onboarding_done: false,
|
onboarding_done: false,
|
||||||
onboarding_started: false,
|
onboarding_started: false,
|
||||||
|
tauri_permissions_done: false,
|
||||||
ppandtos_done: false,
|
ppandtos_done: false,
|
||||||
accepted_terms_of_service: false,
|
accepted_terms_of_service: false,
|
||||||
accepted_privacy_policy: false,
|
accepted_privacy_policy: false,
|
||||||
|
|
@ -577,6 +580,7 @@ export const storageDefaults: Storage = {
|
||||||
":fire:": 2,
|
":fire:": 2,
|
||||||
":white_check_mark:": 1,
|
":white_check_mark:": 1,
|
||||||
},
|
},
|
||||||
|
hotkey_overrides: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// User Status
|
// User Status
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@ export type DesktopScreenShareCapabilities = {
|
||||||
hasReliableSystemAudio: boolean;
|
hasReliableSystemAudio: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ElectronDesktopApi = {
|
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
tensaminDesktop?: {
|
||||||
media?: {
|
media?: {
|
||||||
getScreenShareCapabilities?: () => Promise<DesktopScreenShareCapabilities>;
|
getScreenShareCapabilities?: () => Promise<DesktopScreenShareCapabilities>;
|
||||||
listScreenShareSources?: () => Promise<DesktopScreenShareSource[]>;
|
listScreenShareSources?: () => Promise<DesktopScreenShareSource[]>;
|
||||||
|
|
@ -38,6 +42,13 @@ type ElectronDesktopApi = {
|
||||||
iconDataUrl?: string;
|
iconDataUrl?: string;
|
||||||
}) => Promise<void>;
|
}) => 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?: {
|
secureStorage?: {
|
||||||
getStatus?: () => Promise<{ available: boolean; backend: string | null }>;
|
getStatus?: () => Promise<{ available: boolean; backend: string | null }>;
|
||||||
load?: (key: string) => Promise<string | null>;
|
load?: (key: string) => Promise<string | null>;
|
||||||
|
|
@ -46,10 +57,6 @@ type ElectronDesktopApi = {
|
||||||
clear?: () => Promise<void>;
|
clear?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
tensaminDesktop?: ElectronDesktopApi;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export function log(
|
||||||
| "red"
|
| "red"
|
||||||
| "green"
|
| "green"
|
||||||
| "yellow"
|
| "yellow"
|
||||||
|
| "orange"
|
||||||
| "purple"
|
| "purple"
|
||||||
| "blue"
|
| "blue"
|
||||||
| "cyan"
|
| "cyan"
|
||||||
|
|
@ -32,6 +33,7 @@ export function log(
|
||||||
red: "\x1b[31m",
|
red: "\x1b[31m",
|
||||||
green: "\x1b[32m",
|
green: "\x1b[32m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
|
orange: "\x1b[38;5;208m",
|
||||||
purple: "\x1b[35m",
|
purple: "\x1b[35m",
|
||||||
blue: "\x1b[34m",
|
blue: "\x1b[34m",
|
||||||
cyan: "\x1b[36m",
|
cyan: "\x1b[36m",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
type StringKeyOf<T> = Extract<keyof T, string>;
|
type StringKeyOf<T> = Extract<keyof T, string>;
|
||||||
|
|
||||||
type SettingDefinition = {
|
|
||||||
display: string;
|
|
||||||
type: string;
|
|
||||||
default?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SettingsSchema = Record<
|
export type SettingsSchema = Record<
|
||||||
string,
|
string,
|
||||||
Record<string, Record<string, SettingDefinition>>
|
Record<string, Record<string, {
|
||||||
|
display: string;
|
||||||
|
type: string;
|
||||||
|
default?: unknown;
|
||||||
|
}>>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "@tensamin/shared/indexedDb";
|
} from "@tensamin/shared/indexedDb";
|
||||||
import { ErrorScreen } from "@methanium/ui";
|
import { ErrorScreen } from "@methanium/ui";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
decodeSecureValue,
|
decodeSecureValue,
|
||||||
encodeSecureValue,
|
encodeSecureValue,
|
||||||
|
|
@ -94,6 +95,14 @@ export default function StorageProvider(props: { children: ReactNode }) {
|
||||||
const generation = generations.current.get(key) ?? 0;
|
const generation = generations.current.get(key) ?? 0;
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
try {
|
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 desktopStorage = window.tensaminDesktop?.secureStorage;
|
||||||
const desktopStatus = desktopStorage?.getStatus
|
const desktopStatus = desktopStorage?.getStatus
|
||||||
? await desktopStorage.getStatus()
|
? await desktopStorage.getStatus()
|
||||||
|
|
@ -143,6 +152,10 @@ export default function StorageProvider(props: { children: ReactNode }) {
|
||||||
options: SaveOptions = {},
|
options: SaveOptions = {},
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
|
generations.current.set(key, (generations.current.get(key) ?? 0) + 1);
|
||||||
|
if (key === "mtp_keyring" && isTauri()) {
|
||||||
|
commit(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const desktopStorage = window.tensaminDesktop?.secureStorage;
|
const desktopStorage = window.tensaminDesktop?.secureStorage;
|
||||||
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
|
if (JSON.stringify(value) === JSON.stringify(defaults[key])) {
|
||||||
if (desktopStorage?.delete)
|
if (desktopStorage?.delete)
|
||||||
|
|
|
||||||
8698
pnpm-lock.yaml
generated
8698
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -72,3 +72,211 @@ export const noWindowLocationReload: Rule.RuleModule = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AstNode {
|
||||||
|
type: string;
|
||||||
|
parent: AstNode | null;
|
||||||
|
range: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeAliasDeclaration extends AstNode {
|
||||||
|
type: "TSTypeAliasDeclaration";
|
||||||
|
typeAnnotation: AstNode;
|
||||||
|
typeParameters?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunctionDeclaration extends AstNode {
|
||||||
|
type: "FunctionDeclaration";
|
||||||
|
async: boolean;
|
||||||
|
generator: boolean;
|
||||||
|
params: AstNode[];
|
||||||
|
body: AstNode & { body: AstNode[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReturnStatement extends AstNode {
|
||||||
|
type: "ReturnStatement";
|
||||||
|
argument: AstNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExported(node: AstNode): boolean {
|
||||||
|
return (
|
||||||
|
node.parent?.type === "ExportNamedDeclaration" ||
|
||||||
|
node.parent?.type === "ExportDefaultDeclaration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsContextSensitiveNode(value: unknown): boolean {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
|
||||||
|
const node = value as { type?: string; [key: string]: unknown };
|
||||||
|
if (
|
||||||
|
node.type === "ThisExpression" ||
|
||||||
|
node.type === "Super" ||
|
||||||
|
node.type === "MetaProperty"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(node).some(
|
||||||
|
([key, child]) =>
|
||||||
|
key !== "parent" &&
|
||||||
|
key !== "loc" &&
|
||||||
|
key !== "range" &&
|
||||||
|
containsContextSensitiveNode(child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unambiguousInlineTypes = new Set([
|
||||||
|
"TSAnyKeyword",
|
||||||
|
"TSBigIntKeyword",
|
||||||
|
"TSBooleanKeyword",
|
||||||
|
"TSIntrinsicKeyword",
|
||||||
|
"TSLiteralType",
|
||||||
|
"TSNeverKeyword",
|
||||||
|
"TSNullKeyword",
|
||||||
|
"TSNumberKeyword",
|
||||||
|
"TSObjectKeyword",
|
||||||
|
"TSStringKeyword",
|
||||||
|
"TSSymbolKeyword",
|
||||||
|
"TSThisType",
|
||||||
|
"TSTupleType",
|
||||||
|
"TSTypeLiteral",
|
||||||
|
"TSTypeReference",
|
||||||
|
"TSUndefinedKeyword",
|
||||||
|
"TSUnknownKeyword",
|
||||||
|
"TSVoidKeyword",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const inlineSingleUseDeclarations: Rule.RuleModule = {
|
||||||
|
meta: {
|
||||||
|
type: "suggestion",
|
||||||
|
docs: {
|
||||||
|
description: "Inline local types and functions that are used only once",
|
||||||
|
},
|
||||||
|
fixable: "code",
|
||||||
|
messages: {
|
||||||
|
function: "Inline this function at its only call site.",
|
||||||
|
type: "Inline this type at its only use site.",
|
||||||
|
},
|
||||||
|
schema: [],
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const sourceCode = context.sourceCode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
TSTypeAliasDeclaration(untypedNode: Rule.Node) {
|
||||||
|
const node = untypedNode as unknown as TypeAliasDeclaration;
|
||||||
|
if (node.typeParameters || isExported(node)) return;
|
||||||
|
|
||||||
|
const eslintNode = node as unknown as Rule.Node;
|
||||||
|
const [variable] = sourceCode.getDeclaredVariables(eslintNode);
|
||||||
|
if (!variable || variable.references.length !== 1) return;
|
||||||
|
|
||||||
|
const reference = variable.references[0]
|
||||||
|
.identifier as unknown as AstNode;
|
||||||
|
if (
|
||||||
|
reference.range[0] >= node.range[0] &&
|
||||||
|
reference.range[1] <= node.range[1]
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceParent = reference.parent;
|
||||||
|
if (
|
||||||
|
!referenceParent ||
|
||||||
|
referenceParent.type !== "TSTypeReference" ||
|
||||||
|
referenceParent.parent?.type === "TSClassImplements" ||
|
||||||
|
referenceParent.parent?.type === "TSInterfaceHeritage"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.report({
|
||||||
|
node: eslintNode,
|
||||||
|
messageId: "type",
|
||||||
|
fix(fixer) {
|
||||||
|
const annotation = sourceCode.getText(
|
||||||
|
node.typeAnnotation as unknown as Rule.Node,
|
||||||
|
);
|
||||||
|
const replacement = unambiguousInlineTypes.has(
|
||||||
|
node.typeAnnotation.type,
|
||||||
|
)
|
||||||
|
? annotation
|
||||||
|
: `(${annotation})`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
fixer.replaceText(
|
||||||
|
referenceParent as unknown as Rule.Node,
|
||||||
|
replacement,
|
||||||
|
),
|
||||||
|
fixer.remove(eslintNode),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
FunctionDeclaration(untypedNode: Rule.Node) {
|
||||||
|
const node = untypedNode as unknown as FunctionDeclaration;
|
||||||
|
if (
|
||||||
|
node.async ||
|
||||||
|
node.generator ||
|
||||||
|
node.params.length !== 0 ||
|
||||||
|
node.body.body.length !== 1 ||
|
||||||
|
isExported(node)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statement = node.body.body[0] as ReturnStatement;
|
||||||
|
if (statement.type !== "ReturnStatement" || !statement.argument) return;
|
||||||
|
|
||||||
|
const eslintNode = node as unknown as Rule.Node;
|
||||||
|
const functionScope = sourceCode.getScope(eslintNode);
|
||||||
|
if (
|
||||||
|
functionScope.references.length !== 0 ||
|
||||||
|
functionScope.through.length !== 0 ||
|
||||||
|
containsContextSensitiveNode(statement.argument)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [variable] = sourceCode.getDeclaredVariables(eslintNode);
|
||||||
|
if (!variable || variable.references.length !== 1) return;
|
||||||
|
|
||||||
|
const reference = variable.references[0]
|
||||||
|
.identifier as unknown as AstNode;
|
||||||
|
const call = reference.parent;
|
||||||
|
if (
|
||||||
|
!call ||
|
||||||
|
call.type !== "CallExpression" ||
|
||||||
|
(call as AstNode & { callee: AstNode }).callee !== reference ||
|
||||||
|
(call as AstNode & { arguments: AstNode[] }).arguments.length !== 0 ||
|
||||||
|
(call as AstNode & { optional?: boolean }).optional ||
|
||||||
|
(reference.range[0] >= node.range[0] &&
|
||||||
|
reference.range[1] <= node.range[1])
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.report({
|
||||||
|
node: eslintNode,
|
||||||
|
messageId: "function",
|
||||||
|
fix(fixer) {
|
||||||
|
const expression = sourceCode.getText(
|
||||||
|
statement.argument! as unknown as Rule.Node,
|
||||||
|
);
|
||||||
|
const replacement =
|
||||||
|
statement.argument!.type === "Literal"
|
||||||
|
? expression
|
||||||
|
: `(${expression})`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
fixer.replaceText(call as unknown as Rule.Node, replacement),
|
||||||
|
fixer.remove(eslintNode),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ for (const targetDir of targetDirs) {
|
||||||
const entry = relative(rootDir, fullPath);
|
const entry = relative(rootDir, fullPath);
|
||||||
console.log(`Linting ${entry}...`);
|
console.log(`Linting ${entry}...`);
|
||||||
try {
|
try {
|
||||||
execSync("pnpm run lint", { cwd: fullPath, stdio: "inherit" });
|
execSync("pnpm run lint --fix", { cwd: fullPath, stdio: "inherit" });
|
||||||
console.log(`${entry} linted successfully.`);
|
console.log(`${entry} linted successfully.`);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`Failed to lint ${entry}.`);
|
console.error(`Failed to lint ${entry}.`);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue