Compare commits
73 changed files with 6400 additions and 8660 deletions
1
TODO
1
TODO
|
|
@ -1,4 +1,5 @@
|
||||||
- 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,7 +5,6 @@ import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
globalShortcut,
|
|
||||||
ipcMain,
|
ipcMain,
|
||||||
session,
|
session,
|
||||||
shell,
|
shell,
|
||||||
|
|
@ -14,7 +13,6 @@ 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";
|
||||||
|
|
@ -31,8 +29,6 @@ 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"));
|
||||||
|
|
@ -50,13 +46,6 @@ 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" &&
|
||||||
|
|
@ -253,24 +242,6 @@ 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" ||
|
||||||
|
|
@ -311,90 +282,6 @@ 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", {
|
||||||
|
|
@ -480,10 +367,6 @@ 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,7 +2,6 @@ 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";
|
||||||
|
|
@ -56,23 +55,6 @@ 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,11 +31,6 @@ 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,
|
||||||
|
|
@ -82,7 +77,4 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[env]
|
|
||||||
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
|
|
@ -1,53 +0,0 @@
|
||||||
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,18 +15,12 @@ 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", rev = "20f3f2515c65ca1e115991c3ef3625e2c29c1bbb", features = [] }
|
tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef", 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"
|
||||||
|
|
@ -36,12 +30,10 @@ 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"
|
||||||
rev = "20f3f2515c65ca1e115991c3ef3625e2c29c1bbb"
|
branch = "feat/cef"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
],
|
],
|
||||||
"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,10 +6,7 @@
|
||||||
<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" />
|
||||||
|
|
@ -53,25 +50,6 @@
|
||||||
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(290)
|
webView.setInitialScale(300)
|
||||||
mediaWebView = webView
|
mediaWebView = webView
|
||||||
MobileMediaEvents.attach(webView)
|
MobileMediaEvents.attach(webView)
|
||||||
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
||||||
|
|
@ -65,24 +65,10 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
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.
|
Before Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
|
@ -1,13 +1,7 @@
|
||||||
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(
|
.plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build())
|
||||||
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
|
||||||
|
|
@ -17,21 +11,11 @@ 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());
|
||||||
|
|
||||||
let app = builder
|
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||||
.invoke_handler(tauri::generate_handler![
|
let builder = builder.plugin(tauri_plugin_app_events::init());
|
||||||
mtp_backend::mtp_request,
|
|
||||||
mtp_backend::mtp_status,
|
if let Err(error) = builder
|
||||||
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;
|
||||||
|
|
@ -46,18 +30,9 @@ pub fn run() {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.build(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while building tauri application");
|
{
|
||||||
|
eprintln!("error while running tauri application: {error}");
|
||||||
app.run(|_app, event| {
|
panic!("error while running tauri application: {error}");
|
||||||
#[cfg(target_os = "android")]
|
}
|
||||||
if let tauri::RunEvent::ExitRequested {
|
|
||||||
api, code: None, ..
|
|
||||||
} = event
|
|
||||||
{
|
|
||||||
if mtp_backend::manager().is_enabled() {
|
|
||||||
api.prevent_exit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 = {
|
||||||
|
|
||||||
export const deeplinkContext = createContext<{
|
|
||||||
deeplinks: readonly string[];
|
deeplinks: readonly string[];
|
||||||
} | undefined>(
|
};
|
||||||
|
|
||||||
|
export const deeplinkContext = createContext<DeeplinkContextValue | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@
|
||||||
"@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 { invoke, isTauri } from "@tauri-apps/api/core";
|
import { 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,8 +64,7 @@ function parseTuFileContent(rawFileContent: string): {
|
||||||
throw new Error("Invalid file");
|
throw new Error("Invalid file");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [userIdString, privateKeyValue] = rawFileContent.split("::");
|
const [userIdString, privateKey] = 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);
|
||||||
|
|
@ -88,7 +87,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 { load, save } = useStorage();
|
const { save } = useStorage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const loginPendingRef = useRef(false);
|
const loginPendingRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -98,23 +97,6 @@ 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);
|
||||||
|
|
@ -124,7 +106,7 @@ export default function Form() {
|
||||||
loginPendingRef.current = false;
|
loginPendingRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[load, navigate, save],
|
[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, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
|
import DeeplinkContext 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,7 +49,6 @@ 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");
|
||||||
|
|
||||||
|
|
@ -267,12 +266,10 @@ function RootShell() {
|
||||||
/>
|
/>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Storage>
|
<Storage>
|
||||||
<HotkeysProvider>
|
<ThemeStorageBridge />
|
||||||
<ThemeStorageBridge />
|
<LoginWrapper>
|
||||||
<LoginWrapper>
|
<Outlet />
|
||||||
<Outlet />
|
</LoginWrapper>
|
||||||
</LoginWrapper>
|
|
||||||
</HotkeysProvider>
|
|
||||||
</Storage>
|
</Storage>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -288,7 +285,6 @@ function AppShell() {
|
||||||
<DesktopMediaProvider>
|
<DesktopMediaProvider>
|
||||||
<MTPProvider>
|
<MTPProvider>
|
||||||
<CacheSync />
|
<CacheSync />
|
||||||
<DeeplinkNavigator />
|
|
||||||
<Session>
|
<Session>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<CallInit />
|
<CallInit />
|
||||||
|
|
@ -310,36 +306,6 @@ function AppShell() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeeplinkNavigator() {
|
|
||||||
const { deeplinks } = useDeeplinks();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const handledCount = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const links = deeplinks.slice(handledCount.current);
|
|
||||||
handledCount.current = deeplinks.length;
|
|
||||||
|
|
||||||
for (const link of links) {
|
|
||||||
try {
|
|
||||||
const url = new URL(link);
|
|
||||||
const id = Number(url.searchParams.get("id"));
|
|
||||||
if (
|
|
||||||
url.protocol === "tensamin:" &&
|
|
||||||
url.hostname === "chat" &&
|
|
||||||
Number.isSafeInteger(id) &&
|
|
||||||
id > 0
|
|
||||||
) {
|
|
||||||
void navigate({ to: "/chat", search: { id } });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed URLs delivered by the platform.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [deeplinks, navigate]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCallTrayIcon(color: string, speaking: boolean) {
|
function createCallTrayIcon(color: string, speaking: boolean) {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = 32;
|
canvas.width = 32;
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,9 @@ 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",
|
||||||
|
|
@ -132,7 +134,6 @@ 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,7 +6,6 @@ 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";
|
||||||
|
|
@ -35,7 +34,6 @@ 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,
|
||||||
},
|
},
|
||||||
|
|
@ -44,7 +42,6 @@ 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,8 +376,6 @@
|
||||||
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.11",
|
"version": "0.0.10",
|
||||||
"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": "pnpm run delete:mobile || true && cd apps/tauri && pnpm dev:mobile && pnpm run delete:mobile",
|
"dev:mobile": "cd apps/tauri && pnpm dev: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": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts"
|
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client.dev"
|
||||||
},
|
},
|
||||||
"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,11 +13,9 @@ 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,
|
||||||
|
|
@ -26,7 +24,7 @@ import {
|
||||||
export * from "./helpers";
|
export * from "./helpers";
|
||||||
export * from "./schemas";
|
export * from "./schemas";
|
||||||
|
|
||||||
type CacheStore = "contacts" | "profiles" | "conversations" | "drafts";
|
type CacheStore = "contacts" | "profiles" | "conversations";
|
||||||
|
|
||||||
export interface SecureValueCodec {
|
export interface SecureValueCodec {
|
||||||
encode(value: unknown): unknown | Promise<unknown>;
|
encode(value: unknown): unknown | Promise<unknown>;
|
||||||
|
|
@ -239,25 +237,19 @@ 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(
|
||||||
["contacts", "profiles", "conversations", "drafts"] as CacheStore[]
|
async (store) => {
|
||||||
).map(async (store) => {
|
const storedEntries = await entries(store);
|
||||||
const storedEntries = await entries(store);
|
await Promise.all(
|
||||||
await Promise.all(
|
storedEntries.map(([key]) =>
|
||||||
storedEntries.map(([key]) =>
|
deleteDatabaseEntry("cache", storedKey(store, 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,14 +20,8 @@ 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,6 +17,22 @@ 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,
|
||||||
|
|
@ -25,19 +41,7 @@ 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,16 +8,32 @@ 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?: {
|
tensaminMobileMedia?: MobileMediaApi;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
@ -148,12 +164,7 @@ async function startMobileScreen(
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFrame = (event: Event) => {
|
const onFrame = (event: Event) => {
|
||||||
const detail = eventDetail<{
|
const detail = eventDetail<FrameDetail>(event);
|
||||||
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) {
|
||||||
|
|
@ -168,14 +179,7 @@ async function startMobileScreen(
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAudio = (event: Event) => {
|
const onAudio = (event: Event) => {
|
||||||
const bytes = decodeBase64(
|
const bytes = decodeBase64(eventDetail<AudioDetail>(event).data);
|
||||||
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,19 +10,18 @@ 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<
|
private entries = new Map<number, AnalyserEntry>();
|
||||||
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,10 +1,12 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
const useSpeakingState = create<{
|
type SpeakingState = {
|
||||||
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,6 +51,7 @@ 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"]
|
||||||
|
|
@ -62,9 +63,18 @@ 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>,
|
||||||
|
|
@ -74,15 +84,44 @@ type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>;
|
||||||
type RemoteVideoTrackSelector = Track.Kind | Track.Source;
|
type RemoteVideoTrackSelector = Track.Kind | Track.Source;
|
||||||
|
|
||||||
type Runtime = {
|
type Runtime = {
|
||||||
navigate: (options: {
|
navigate: NavigateFn;
|
||||||
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;
|
||||||
|
|
@ -1156,41 +1195,7 @@ async function ensureNoiseFilter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCall = create<{
|
export const useCall = create<CallStore>(() => ({
|
||||||
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,7 +22,6 @@
|
||||||
"@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,25 +34,35 @@ 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<
|
file?: Record<string, KlipyMediaFormats | undefined>;
|
||||||
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;
|
||||||
|
|
@ -95,11 +105,7 @@ async function fetchKlipyPage({
|
||||||
kind: KlipyKind;
|
kind: KlipyKind;
|
||||||
page: number;
|
page: number;
|
||||||
search: string;
|
search: string;
|
||||||
}): Promise<{
|
}): Promise<KlipyPage> {
|
||||||
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),
|
||||||
|
|
@ -122,13 +128,7 @@ 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 {
|
const body = (await response.json()) as KlipyResponse;
|
||||||
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, { type InputController } from "@tensamin/markdown/input";
|
import Input 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, { useCallback, useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { Button } from "@methanium/ui";
|
import { Button } from "@methanium/ui";
|
||||||
|
|
||||||
import { Plus, Laugh, FileVideo, SendHorizonal } from "lucide-react";
|
import { Plus, Laugh, FileVideo } 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,17 +22,13 @@ 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);
|
||||||
|
|
||||||
|
|
@ -56,65 +52,6 @@ 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) => {
|
||||||
|
|
@ -258,7 +195,6 @@ 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)}
|
||||||
|
|
@ -274,27 +210,18 @@ export default function InputComponent({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="relative p-0 flex flex-col">
|
<CardHeader className="relative p-0 flex flex-col">
|
||||||
<div className="w-full flex items-center">
|
<Input
|
||||||
<Input
|
className="w-full"
|
||||||
className="w-full"
|
paddingY="13px"
|
||||||
onControllerChange={setComposer}
|
paddingX="13px"
|
||||||
paddingY="13px"
|
placeholder="Send a message..."
|
||||||
paddingX="13px"
|
value={value}
|
||||||
placeholder="Send a message..."
|
setValue={setValue}
|
||||||
value={value}
|
onSubmit={handleSubmit}
|
||||||
setValue={setValue}
|
invertEnterBehavior={invertEnterBehavior}
|
||||||
onSubmit={handleSubmit}
|
emojiFrequencies={emojiFrequencies}
|
||||||
invertEnterBehavior={invertEnterBehavior}
|
onEmojiSelect={recordUse}
|
||||||
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, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useState } from "react";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,23 +24,17 @@ 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 =
|
||||||
|
|
@ -54,7 +48,6 @@ 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);
|
||||||
|
|
@ -148,6 +141,7 @@ function MessageComponent({
|
||||||
chatSecret,
|
chatSecret,
|
||||||
editMessage,
|
editMessage,
|
||||||
userId,
|
userId,
|
||||||
|
deleteMessage,
|
||||||
removeReaction,
|
removeReaction,
|
||||||
replyTo,
|
replyTo,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
@ -189,27 +183,8 @@ function MessageComponent({
|
||||||
};
|
};
|
||||||
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
||||||
const recordUse = useRecordEmojiUse();
|
const recordUse = useRecordEmojiUse();
|
||||||
const editingRef = useRef(editing);
|
const [editing, setEditing] = useState(false);
|
||||||
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);
|
||||||
|
|
@ -296,14 +271,12 @@ 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"
|
||||||
|
|
@ -316,7 +289,7 @@ function MessageComponent({
|
||||||
isOwnMessage={message.SenderId === ownId}
|
isOwnMessage={message.SenderId === ownId}
|
||||||
messageId={message.SendTime}
|
messageId={message.SendTime}
|
||||||
onReact={toggleReaction}
|
onReact={toggleReaction}
|
||||||
onSetEditing={onSetEditing}
|
onSetEditing={setEditing}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|
@ -332,7 +305,7 @@ function MessageComponent({
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{grouped ? (
|
{grouped ? (
|
||||||
<p className="w-9 self-start pt-[5px] text-xs group-hover:visible invisible text-muted-foreground">
|
<p className="w-9 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",
|
||||||
|
|
@ -389,17 +362,13 @@ 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={() => {
|
||||||
if (!editDraft.trim()) return;
|
submitEditMessage(editDraft);
|
||||||
if (editDraft !== message.Content) {
|
setEditing(false);
|
||||||
void submitEditMessage(editDraft);
|
|
||||||
}
|
|
||||||
onSetEditing(false);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
@ -408,12 +377,13 @@ function MessageComponent({
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-primary-foreground-alt"
|
className="text-primary-foreground-alt"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!editDraft.trim()) return;
|
if (editDraft === message.Content) {
|
||||||
if (editDraft !== message.Content) {
|
deleteMessage(message.SendTime);
|
||||||
void submitEditMessage(editDraft);
|
} else {
|
||||||
|
submitEditMessage(editDraft);
|
||||||
}
|
}
|
||||||
setEditDraft(message.Content);
|
setEditDraft(message.Content);
|
||||||
onSetEditing(false);
|
setEditing(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
|
|
@ -422,25 +392,19 @@ function MessageComponent({
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onClick={cancelEditing}
|
onClick={() => {
|
||||||
|
setEditDraft(message.Content);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : isValidURL ? (
|
||||||
|
<Media link={message.Content} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-1">
|
<Text value={message.Content} />
|
||||||
{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">
|
||||||
|
|
@ -481,7 +445,6 @@ 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,7 +28,6 @@ function ReplyUser({ user }: { user: User }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReplyBox({
|
export default function ReplyBox({
|
||||||
edited,
|
|
||||||
content,
|
content,
|
||||||
loading = false,
|
loading = false,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
|
@ -36,7 +35,6 @@ export default function ReplyBox({
|
||||||
userId,
|
userId,
|
||||||
variant,
|
variant,
|
||||||
}: {
|
}: {
|
||||||
edited?: boolean;
|
|
||||||
content?: string;
|
content?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
|
@ -76,13 +74,8 @@ export default function ReplyBox({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{content !== undefined && (
|
{content !== undefined && (
|
||||||
<div className="h-5.5 min-w-0 flex-1 truncate flex gap-1">
|
<div className="h-5.5 min-w-0 flex-1 truncate">
|
||||||
<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, type ChatDraft } from "@tensamin/cache";
|
import { createCache } 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,16 +145,7 @@ type SendMessageGet = (
|
||||||
data: { SendTime: number },
|
data: { SendTime: number },
|
||||||
) => Promise<{ data: RawMessage }>;
|
) => Promise<{ data: RawMessage }>;
|
||||||
|
|
||||||
type StoredDraftState = ChatDraft & {
|
type GetChatSecret = (userId: number) => Promise<Uint8Array | null>;
|
||||||
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,
|
||||||
|
|
@ -202,7 +193,7 @@ export async function fetchReplyMessage({
|
||||||
ownId: number;
|
ownId: number;
|
||||||
chatUserId: number;
|
chatUserId: number;
|
||||||
send: SendMessageGet;
|
send: SendMessageGet;
|
||||||
getChatSecret: (userId: number) => Promise<Uint8Array | null>;
|
getChatSecret: GetChatSecret;
|
||||||
}) {
|
}) {
|
||||||
const message = await getMessage({
|
const message = await getMessage({
|
||||||
sendTime: replyTo,
|
sendTime: replyTo,
|
||||||
|
|
@ -235,10 +226,6 @@ 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;
|
||||||
|
|
@ -271,142 +258,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -1056,6 +907,12 @@ 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
|
||||||
|
|
@ -1070,13 +927,10 @@ 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,
|
||||||
}}
|
}}
|
||||||
|
|
@ -1100,13 +954,10 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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,6 +21,12 @@ 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,
|
||||||
|
|
@ -44,69 +50,12 @@ 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: {
|
const chunks: MessageChunk[] = [];
|
||||||
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);
|
||||||
|
|
@ -136,15 +85,10 @@ 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);
|
||||||
|
|
@ -158,26 +102,11 @@ 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 [composerHeight, setComposerHeight] = useState(0);
|
const [value, setValue] = useState("");
|
||||||
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,
|
||||||
|
|
@ -212,7 +141,6 @@ 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(() => {
|
||||||
|
|
@ -280,40 +208,11 @@ 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") {
|
||||||
|
|
@ -334,26 +233,6 @@ 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;
|
||||||
|
|
@ -403,6 +282,22 @@ 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;
|
||||||
|
|
@ -541,7 +436,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}
|
||||||
|
|
@ -580,15 +475,8 @@ 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) ===
|
||||||
|
|
@ -600,24 +488,11 @@ export default function Screen() {
|
||||||
userId={message.SenderId}
|
userId={message.SenderId}
|
||||||
loading={null}
|
loading={null}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<>
|
<Message
|
||||||
{dateLabel && (
|
grouped={isGrouped}
|
||||||
<DateSeparator label={dateLabel} />
|
message={message}
|
||||||
)}
|
user={user}
|
||||||
<Message
|
/>
|
||||||
editing={
|
|
||||||
editingMessageId === message.SendTime
|
|
||||||
}
|
|
||||||
grouped={isGrouped}
|
|
||||||
message={message}
|
|
||||||
onSetEditing={(editing) =>
|
|
||||||
setEditingMessageId(
|
|
||||||
editing ? message.SendTime : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -628,12 +503,8 @@ export default function Screen() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref={composerRef} className="absolute inset-x-0 bottom-0 z-10">
|
<div className="z-10 shrink-0">
|
||||||
<InputComponent
|
<InputComponent setValue={setValue} value={value} />
|
||||||
onEditLastMessage={editLastMessage}
|
|
||||||
setValue={setComposerValue}
|
|
||||||
value={composerValue}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
- 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
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
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]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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";
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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("+");
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"types": ["vite/client"]
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
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;
|
||||||
|
|
@ -15,7 +17,7 @@ function normalizeName(value: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emojis: readonly EmojiDefinition[] = Object.entries(
|
export const emojis: readonly EmojiDefinition[] = Object.entries(
|
||||||
shortcodeData as Record<string, string | string[]>,
|
shortcodeData as Record<string, ShortcodeValue>,
|
||||||
).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,12 +52,6 @@ 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;
|
||||||
|
|
@ -72,8 +66,10 @@ 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 {
|
||||||
|
|
@ -95,6 +91,11 @@ 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" });
|
||||||
|
|
@ -315,7 +316,6 @@ 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,16 +326,10 @@ 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;
|
||||||
|
|
@ -344,7 +338,7 @@ export default function Input(props: InputProps) {
|
||||||
doc: props.value,
|
doc: props.value,
|
||||||
extensions: createEditorExtensions(
|
extensions: createEditorExtensions(
|
||||||
(value) => {
|
(value) => {
|
||||||
setValueRef.current(value);
|
props.setValue(value);
|
||||||
},
|
},
|
||||||
() => props.placeholder,
|
() => props.placeholder,
|
||||||
() => invertEnterBehaviorRef.current,
|
() => invertEnterBehaviorRef.current,
|
||||||
|
|
@ -359,23 +353,8 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
@ -434,9 +413,7 @@ export default function Input(props: InputProps) {
|
||||||
props.paddingX,
|
props.paddingX,
|
||||||
Boolean(props.styled),
|
Boolean(props.styled),
|
||||||
),
|
),
|
||||||
} as CSSProperties & {
|
} as InputStyle
|
||||||
"--tm-md-content-padding"?: string;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -827,10 +804,7 @@ 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: {
|
token: TokenRange,
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
},
|
|
||||||
): void {
|
): void {
|
||||||
if (token.from >= token.to) return;
|
if (token.from >= token.to) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import {
|
import { Fragment, type ReactElement, type ReactNode } from "react";
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -30,11 +23,43 @@ 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[];
|
||||||
|
|
@ -42,140 +67,17 @@ type TableBlock = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type MarkdownBlock =
|
type MarkdownBlock =
|
||||||
| {
|
| ParagraphBlock
|
||||||
type: "paragraph";
|
| HeadingBlock
|
||||||
text: string;
|
| HrBlock
|
||||||
}
|
| BlockQuoteBlock
|
||||||
| {
|
| CodeBlock
|
||||||
type: "heading";
|
| ListBlock
|
||||||
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.
|
||||||
|
|
@ -527,7 +429,11 @@ function renderInline(nodes: InlineNode[]): ReactNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === "code") {
|
if (node.type === "code") {
|
||||||
return <CopyableCode key={index} value={node.value} />;
|
return (
|
||||||
|
<code key={index} className="tm-md-code">
|
||||||
|
{node.value}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === "link") {
|
if (node.type === "link") {
|
||||||
|
|
@ -617,12 +523,11 @@ export function renderBlocks(blocks: MarkdownBlock[]): ReactElement {
|
||||||
|
|
||||||
if (block.type === "code") {
|
if (block.type === "code") {
|
||||||
return (
|
return (
|
||||||
<CopyableCode
|
<pre key={blockIndex} className="tm-md-pre">
|
||||||
key={blockIndex}
|
<code className="tm-md-codeblock" data-language={block.language}>
|
||||||
block
|
{block.code}
|
||||||
language={block.language}
|
</code>
|
||||||
value={block.code}
|
</pre>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -761,7 +666,7 @@ function readTable(
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownStyles = `
|
const markdownStyles = `
|
||||||
.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; }
|
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.65; font-size: 1rem; }
|
||||||
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
|
.tm-md-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; }
|
||||||
|
|
@ -771,15 +676,13 @@ 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: var(--muted); overflow-x: auto; }
|
.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: hsl(var(--muted)); overflow-x: auto; }
|
||||||
.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; }
|
.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||||
.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); }
|
.tm-md-code { padding: 0.08rem 0.32rem; border-radius: 0.28rem; background: hsl(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: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; }
|
.tm-md-link { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 0.14rem; }
|
||||||
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
|
.tm-md-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; }
|
||||||
|
|
@ -788,7 +691,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: var(--muted); font-weight: 600; }
|
.tm-md-table th { background: hsl(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); }
|
||||||
|
|
@ -796,10 +699,10 @@ const markdownStyles = `
|
||||||
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
|
.cm-editor.tm-md-editor .cm-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: var(--foreground); }
|
.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
|
||||||
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
|
.cm-editor.tm-md-editor .tm-md-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: var(--muted); border-radius: 0.3rem; }
|
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
||||||
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
|
.cm-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; }
|
||||||
|
|
@ -823,15 +726,10 @@ export function ensureMarkdownStyles(): void {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
const styleId = "tensamin-markdown-styles";
|
const styleId = "tensamin-markdown-styles";
|
||||||
let style = document.getElementById(styleId) as HTMLStyleElement | null;
|
if (document.getElementById(styleId)) return;
|
||||||
|
|
||||||
if (!style) {
|
const style = document.createElement("style");
|
||||||
style = document.createElement("style");
|
style.id = styleId;
|
||||||
style.id = styleId;
|
style.textContent = markdownStyles;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
|
||||||
|
|
||||||
if (style.textContent !== markdownStyles) {
|
|
||||||
style.textContent = markdownStyles;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"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 { invoke, isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { onResume } from "tauri-plugin-app-events-api";
|
||||||
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,6 +92,10 @@ 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;
|
||||||
|
|
@ -139,21 +143,7 @@ function validateResponse<T extends keyof Schemas & string>(
|
||||||
} as ProtocolMessage<T>;
|
} as ProtocolMessage<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useMessageHandlers() {
|
export function Provider(props: {
|
||||||
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;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -172,8 +162,8 @@ function BrowserProvider(props: {
|
||||||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
|
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||||
useMessageHandlers();
|
const pushHandlersRef = useRef(new Set<PushHandler>());
|
||||||
|
|
||||||
const connected = readyState === ConnectionState.Connected;
|
const connected = readyState === ConnectionState.Connected;
|
||||||
|
|
||||||
|
|
@ -213,6 +203,16 @@ function BrowserProvider(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,6 +223,7 @@ function BrowserProvider(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 = () => {
|
||||||
|
|
@ -291,6 +292,8 @@ function BrowserProvider(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"),
|
||||||
|
|
@ -523,13 +526,37 @@ function BrowserProvider(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);
|
||||||
|
|
@ -537,7 +564,7 @@ function BrowserProvider(props: {
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
sonnerToast.dismiss("mtp-connection-toast");
|
sonnerToast.dismiss("mtp-connection-toast");
|
||||||
};
|
};
|
||||||
}, [mtpUrl, props.blockConnection, load, pushHandlersRef]);
|
}, [mtpUrl, props.blockConnection, load]);
|
||||||
|
|
||||||
// No Iota check
|
// No Iota check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -599,7 +626,7 @@ function BrowserProvider(props: {
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
[interceptorsRef, mtpRef],
|
[mtpRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -623,234 +650,6 @@ function BrowserProvider(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 { invoke, isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
isPermissionGranted as isTauriNotificationPermissionGranted,
|
isPermissionGranted as isTauriNotificationPermissionGranted,
|
||||||
requestPermission as requestTauriNotificationPermission,
|
requestPermission as requestTauriNotificationPermission,
|
||||||
|
|
@ -102,36 +102,12 @@ 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) {
|
||||||
let handledNatively = false;
|
sendTauriNotification({ title: user.Display, body: content });
|
||||||
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,8 +12,6 @@
|
||||||
"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,12 +8,10 @@ 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,
|
||||||
|
|
@ -21,15 +19,14 @@ export {
|
||||||
type OnboardingStepControls,
|
type OnboardingStepControls,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
|
|
||||||
|
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
||||||
|
|
||||||
interface GateState {
|
interface GateState {
|
||||||
docs: z.infer<typeof legalDocsSchema>;
|
docs: LegalDocs;
|
||||||
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 }) {
|
||||||
|
|
@ -75,14 +72,12 @@ 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;
|
||||||
|
|
@ -109,10 +104,6 @@ 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;
|
||||||
|
|
@ -151,11 +142,8 @@ 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, state?.includeTauriPermissions]);
|
}, [save, state?.includeOnboarding]);
|
||||||
|
|
||||||
if (error && errorDescription) {
|
if (error && errorDescription) {
|
||||||
return <ErrorScreen error={error} description={errorDescription} />;
|
return <ErrorScreen error={error} description={errorDescription} />;
|
||||||
|
|
@ -186,16 +174,6 @@ 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: z.infer<typeof legalDocsSchema>;
|
docs: LegalDocs;
|
||||||
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-2 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-10 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
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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,7 +14,6 @@
|
||||||
"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,7 +5,9 @@ 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;
|
||||||
|
|
@ -19,9 +21,7 @@ export function Switch({
|
||||||
id,
|
id,
|
||||||
}: {
|
}: {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
id: keyof typeof settingsStorageDefaults & ({
|
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
||||||
[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,7 +6,6 @@ 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 },
|
||||||
|
|
@ -26,12 +25,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
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,7 +143,25 @@ 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({
|
||||||
|
|
@ -436,7 +454,6 @@ 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;
|
||||||
|
|
@ -454,25 +471,7 @@ 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: Record<
|
theme_palette: Base16Palette | null;
|
||||||
| "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";
|
||||||
|
|
@ -488,7 +487,6 @@ 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 = {
|
||||||
|
|
@ -497,7 +495,6 @@ 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,
|
||||||
|
|
@ -580,7 +577,6 @@ export const storageDefaults: Storage = {
|
||||||
":fire:": 2,
|
":fire:": 2,
|
||||||
":white_check_mark:": 1,
|
":white_check_mark:": 1,
|
||||||
},
|
},
|
||||||
hotkey_overrides: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// User Status
|
// User Status
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,7 @@ 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[]>;
|
||||||
|
|
@ -42,13 +38,6 @@ declare global {
|
||||||
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>;
|
||||||
|
|
@ -57,6 +46,10 @@ declare global {
|
||||||
clear?: () => Promise<void>;
|
clear?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
tensaminDesktop?: ElectronDesktopApi;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ export function log(
|
||||||
| "red"
|
| "red"
|
||||||
| "green"
|
| "green"
|
||||||
| "yellow"
|
| "yellow"
|
||||||
| "orange"
|
|
||||||
| "purple"
|
| "purple"
|
||||||
| "blue"
|
| "blue"
|
||||||
| "cyan"
|
| "cyan"
|
||||||
|
|
@ -33,7 +32,6 @@ 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 = {
|
||||||
|
|
||||||
export type SettingsSchema = Record<
|
|
||||||
string,
|
|
||||||
Record<string, Record<string, {
|
|
||||||
display: string;
|
display: string;
|
||||||
type: string;
|
type: string;
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
}>>
|
};
|
||||||
|
|
||||||
|
export type SettingsSchema = Record<
|
||||||
|
string,
|
||||||
|
Record<string, Record<string, SettingDefinition>>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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,
|
||||||
|
|
@ -95,14 +94,6 @@ 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()
|
||||||
|
|
@ -152,10 +143,6 @@ 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,211 +72,3 @@ export const noWindowLocationReload: Rule.RuleModule = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AstNode {
|
|
||||||
type: string;
|
|
||||||
parent: AstNode | null;
|
|
||||||
range: [number, number];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TypeAliasDeclaration extends AstNode {
|
|
||||||
type: "TSTypeAliasDeclaration";
|
|
||||||
typeAnnotation: AstNode;
|
|
||||||
typeParameters?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FunctionDeclaration extends AstNode {
|
|
||||||
type: "FunctionDeclaration";
|
|
||||||
async: boolean;
|
|
||||||
generator: boolean;
|
|
||||||
params: AstNode[];
|
|
||||||
body: AstNode & { body: AstNode[] };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReturnStatement extends AstNode {
|
|
||||||
type: "ReturnStatement";
|
|
||||||
argument: AstNode | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExported(node: AstNode): boolean {
|
|
||||||
return (
|
|
||||||
node.parent?.type === "ExportNamedDeclaration" ||
|
|
||||||
node.parent?.type === "ExportDefaultDeclaration"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function containsContextSensitiveNode(value: unknown): boolean {
|
|
||||||
if (!value || typeof value !== "object") return false;
|
|
||||||
|
|
||||||
const node = value as { type?: string; [key: string]: unknown };
|
|
||||||
if (
|
|
||||||
node.type === "ThisExpression" ||
|
|
||||||
node.type === "Super" ||
|
|
||||||
node.type === "MetaProperty"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(node).some(
|
|
||||||
([key, child]) =>
|
|
||||||
key !== "parent" &&
|
|
||||||
key !== "loc" &&
|
|
||||||
key !== "range" &&
|
|
||||||
containsContextSensitiveNode(child),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unambiguousInlineTypes = new Set([
|
|
||||||
"TSAnyKeyword",
|
|
||||||
"TSBigIntKeyword",
|
|
||||||
"TSBooleanKeyword",
|
|
||||||
"TSIntrinsicKeyword",
|
|
||||||
"TSLiteralType",
|
|
||||||
"TSNeverKeyword",
|
|
||||||
"TSNullKeyword",
|
|
||||||
"TSNumberKeyword",
|
|
||||||
"TSObjectKeyword",
|
|
||||||
"TSStringKeyword",
|
|
||||||
"TSSymbolKeyword",
|
|
||||||
"TSThisType",
|
|
||||||
"TSTupleType",
|
|
||||||
"TSTypeLiteral",
|
|
||||||
"TSTypeReference",
|
|
||||||
"TSUndefinedKeyword",
|
|
||||||
"TSUnknownKeyword",
|
|
||||||
"TSVoidKeyword",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const inlineSingleUseDeclarations: Rule.RuleModule = {
|
|
||||||
meta: {
|
|
||||||
type: "suggestion",
|
|
||||||
docs: {
|
|
||||||
description: "Inline local types and functions that are used only once",
|
|
||||||
},
|
|
||||||
fixable: "code",
|
|
||||||
messages: {
|
|
||||||
function: "Inline this function at its only call site.",
|
|
||||||
type: "Inline this type at its only use site.",
|
|
||||||
},
|
|
||||||
schema: [],
|
|
||||||
},
|
|
||||||
create(context) {
|
|
||||||
const sourceCode = context.sourceCode;
|
|
||||||
|
|
||||||
return {
|
|
||||||
TSTypeAliasDeclaration(untypedNode: Rule.Node) {
|
|
||||||
const node = untypedNode as unknown as TypeAliasDeclaration;
|
|
||||||
if (node.typeParameters || isExported(node)) return;
|
|
||||||
|
|
||||||
const eslintNode = node as unknown as Rule.Node;
|
|
||||||
const [variable] = sourceCode.getDeclaredVariables(eslintNode);
|
|
||||||
if (!variable || variable.references.length !== 1) return;
|
|
||||||
|
|
||||||
const reference = variable.references[0]
|
|
||||||
.identifier as unknown as AstNode;
|
|
||||||
if (
|
|
||||||
reference.range[0] >= node.range[0] &&
|
|
||||||
reference.range[1] <= node.range[1]
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const referenceParent = reference.parent;
|
|
||||||
if (
|
|
||||||
!referenceParent ||
|
|
||||||
referenceParent.type !== "TSTypeReference" ||
|
|
||||||
referenceParent.parent?.type === "TSClassImplements" ||
|
|
||||||
referenceParent.parent?.type === "TSInterfaceHeritage"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.report({
|
|
||||||
node: eslintNode,
|
|
||||||
messageId: "type",
|
|
||||||
fix(fixer) {
|
|
||||||
const annotation = sourceCode.getText(
|
|
||||||
node.typeAnnotation as unknown as Rule.Node,
|
|
||||||
);
|
|
||||||
const replacement = unambiguousInlineTypes.has(
|
|
||||||
node.typeAnnotation.type,
|
|
||||||
)
|
|
||||||
? annotation
|
|
||||||
: `(${annotation})`;
|
|
||||||
|
|
||||||
return [
|
|
||||||
fixer.replaceText(
|
|
||||||
referenceParent as unknown as Rule.Node,
|
|
||||||
replacement,
|
|
||||||
),
|
|
||||||
fixer.remove(eslintNode),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
FunctionDeclaration(untypedNode: Rule.Node) {
|
|
||||||
const node = untypedNode as unknown as FunctionDeclaration;
|
|
||||||
if (
|
|
||||||
node.async ||
|
|
||||||
node.generator ||
|
|
||||||
node.params.length !== 0 ||
|
|
||||||
node.body.body.length !== 1 ||
|
|
||||||
isExported(node)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statement = node.body.body[0] as ReturnStatement;
|
|
||||||
if (statement.type !== "ReturnStatement" || !statement.argument) return;
|
|
||||||
|
|
||||||
const eslintNode = node as unknown as Rule.Node;
|
|
||||||
const functionScope = sourceCode.getScope(eslintNode);
|
|
||||||
if (
|
|
||||||
functionScope.references.length !== 0 ||
|
|
||||||
functionScope.through.length !== 0 ||
|
|
||||||
containsContextSensitiveNode(statement.argument)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [variable] = sourceCode.getDeclaredVariables(eslintNode);
|
|
||||||
if (!variable || variable.references.length !== 1) return;
|
|
||||||
|
|
||||||
const reference = variable.references[0]
|
|
||||||
.identifier as unknown as AstNode;
|
|
||||||
const call = reference.parent;
|
|
||||||
if (
|
|
||||||
!call ||
|
|
||||||
call.type !== "CallExpression" ||
|
|
||||||
(call as AstNode & { callee: AstNode }).callee !== reference ||
|
|
||||||
(call as AstNode & { arguments: AstNode[] }).arguments.length !== 0 ||
|
|
||||||
(call as AstNode & { optional?: boolean }).optional ||
|
|
||||||
(reference.range[0] >= node.range[0] &&
|
|
||||||
reference.range[1] <= node.range[1])
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
context.report({
|
|
||||||
node: eslintNode,
|
|
||||||
messageId: "function",
|
|
||||||
fix(fixer) {
|
|
||||||
const expression = sourceCode.getText(
|
|
||||||
statement.argument! as unknown as Rule.Node,
|
|
||||||
);
|
|
||||||
const replacement =
|
|
||||||
statement.argument!.type === "Literal"
|
|
||||||
? expression
|
|
||||||
: `(${expression})`;
|
|
||||||
|
|
||||||
return [
|
|
||||||
fixer.replaceText(call as unknown as Rule.Node, replacement),
|
|
||||||
fixer.remove(eslintNode),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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 --fix", { cwd: fullPath, stdio: "inherit" });
|
execSync("pnpm run lint", { 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