diff --git a/Cargo.lock b/Cargo.lock index 705fc3f..f506284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1073,7 +1073,7 @@ dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1808,6 +1808,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1815,7 +1824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1829,6 +1838,12 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2674,6 +2689,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -3850,6 +3881,23 @@ dependencies = [ "web-time", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -4289,6 +4337,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -5222,10 +5314,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -5236,6 +5330,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -5541,6 +5636,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -5624,6 +5728,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -6907,6 +7034,16 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -8398,6 +8535,7 @@ dependencies = [ "jacquard", "log", "notify", + "reqwest 0.12.28", "serde", "serde_json", "tauri", diff --git a/docs/at-proto.md b/docs/at-proto.md index b76e537..82ce3af 100644 --- a/docs/at-proto.md +++ b/docs/at-proto.md @@ -112,7 +112,8 @@ Jacquard types use zero-copy deserialization via `CowStr<'_>`. Use `.parse()` fo src-tauri/src/ ├── atproto/ │ ├── mod.rs # re-exports shared auth types/state -│ └── auth.rs # OAuth loopback flow, session restore, logout cleanup +│ ├── auth.rs # OAuth loopback flow, session restore, logout cleanup +│ └── strings.rs # Tangled string listing + fetch helpers ``` `AtProtoState` lives inside `AppState` and owns the Jacquard OAuth client plus persisted session metadata paths. The current auth slice restores an existing session during app startup, exposes the active `SessionInfo`, and clears persisted auth artifacts when restoration or logout fails. @@ -136,19 +137,28 @@ src-tauri/src/ ```sh src/ -├── state/stores/ui.ts # auth sheet mode + hydrated/pending/session state +├── state/stores/ui.ts # auth/import sheet mode + hydrated/pending/session state ├── state/selectors.ts # AT Protocol selector hooks -├── ports/commands.ts # atproto_login / logout / session_status +├── ports/commands.ts # atproto_login / logout / session_status / string_list / string_get ├── hooks/controllers/ │ └── useAtProtoController.ts ├── components/ │ ├── AtProto/ -│ │ └── AtProtoAuthSheet.tsx +│ │ └── AtProtoAuthSheet.tsx # login, session, and import browser sheet │ └── AppLayout/LayoutSettingsPanel/ │ └── AtProtoSection.tsx ``` -Auth UI is launched from the toolbar `@` button. When no session exists it opens the login sheet; when a session exists it opens the session indicator sheet. Logout is available from both the session sheet and the full settings panel. +Auth UI is launched from the toolbar `@` button. When no session exists it opens the login sheet; when a session exists it opens the session indicator sheet. The login sheet also exposes a public "Browse Public Strings" path, and the session sheet exposes an "Import Strings" action that opens the pull browser. Logout is available from both the session sheet and the full settings panel. + +The pull browser flow is: + +1. Enter a handle or DID and call `string_list`. +2. Select a string and hydrate the preview with `string_get`. +3. Choose a Writer location + relative path. +4. Import with `doc_exists` guard + `doc_save`. + +Imported non-Markdown/non-plaintext strings are wrapped in fenced code blocks using the source filename extension as the language tag when possible. ### Sync & Origin Tracking diff --git a/docs/roadmap.md b/docs/roadmap.md index a96717c..425d53b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,6 +26,8 @@ Publish documents as [Tangled strings](https://tangled.sh) (AT Protocol gists) a - Extensions covered: `py`, `md`, `js`, `ts`, `yaml`, `java`, `sass`, `css`, `csv`, `fs`, `cs` - `i-fluent-document-16-filled` for fallback +Implemented on `2026-03-19` in the Tauri backend, AT Proto controller, and unified auth/import sheet. + ### Part 3 — Push 1. **Tauri commands** - `string_create`, `string_update`, `string_delete` diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index db0a62b..0a3ecd1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ log = "0.4" async-trait = "0.1" thiserror = "2" jacquard = { version = "0.9.5", features = ["default"] } +reqwest = "0.12" writer-core = { path = "../crates/core" } writer-md = { path = "../crates/markdown" } diff --git a/src-tauri/src/atproto/auth.rs b/src-tauri/src/atproto/auth.rs index 54eec77..1a88813 100644 --- a/src-tauri/src/atproto/auth.rs +++ b/src-tauri/src/atproto/auth.rs @@ -1,10 +1,15 @@ use jacquard::client::FileAuthStore; +use jacquard::identity::resolver::IdentityResolver; use jacquard::oauth::client::{OAuthClient, OAuthSession}; use jacquard::oauth::loopback::{LoopbackConfig, LoopbackPort}; use jacquard::types::did::Did; +use jacquard::types::ident::AtIdentifier; +use jacquard::IntoStatic; +use reqwest::Client as HttpClient; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; +use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; @@ -45,6 +50,8 @@ struct StoredOAuthEntry { pub struct AtProtoState { oauth: Arc>, + resolver: Arc, + pub(crate) http: HttpClient, auth_store_path: PathBuf, session_meta_path: PathBuf, session_info: Mutex>, @@ -57,9 +64,18 @@ impl AtProtoState { let auth_store_path = app_dir.join(AUTH_STORE_FILENAME); let session_meta_path = app_dir.join(SESSION_META_FILENAME); + let http = HttpClient::new(); + let resolver = OAuthResolver::new(http.clone(), Default::default()); let oauth = OAuthClient::with_default_config(FileAuthStore::new(&auth_store_path)); - Ok(Self { oauth: Arc::new(oauth), auth_store_path, session_meta_path, session_info: Mutex::new(None) }) + Ok(Self { + oauth: Arc::new(oauth), + resolver: Arc::new(resolver), + http, + auth_store_path, + session_meta_path, + session_info: Mutex::new(None), + }) } pub async fn restore_session(&self) -> Result, AppError> { @@ -110,7 +126,7 @@ impl AtProtoState { Default::default(), LoopbackConfig { host: "127.0.0.1".into(), - port: LoopbackPort::Ephemeral, + port: LoopbackPort::Fixed(reserve_loopback_port()?), open_browser: true, timeout_ms: 5 * 60 * 1000, }, @@ -214,6 +230,50 @@ impl AtProtoState { Some(PersistedSessionMeta { did: did.clone(), handle: did, session_id: entry.oauth.session_id }) } + + pub(crate) async fn resolve_repo_and_pds( + &self, did_or_handle: &str, + ) -> Result<(Did<'static>, reqwest::Url), AppError> { + let trimmed = did_or_handle.trim(); + if trimmed.is_empty() { + return Err(AppError::new( + ErrorCode::InvalidPath, + "Handle or DID is required to browse Tangled strings", + )); + } + + let identifier = AtIdentifier::new(trimmed).map_err(|error| { + AppError::new( + ErrorCode::Parse, + format!("Invalid AT Protocol handle or DID: {}", error), + ) + })?; + + match identifier { + AtIdentifier::Did(did) => { + let pds = self + .resolver + .pds_for_did(&did) + .await + .map_err(|error| AppError::io(format!("Failed to resolve PDS for DID: {}", error)))?; + Ok((did.into_static(), pds)) + } + AtIdentifier::Handle(handle) => self + .resolver + .pds_for_handle(&handle) + .await + .map_err(|error| AppError::io(format!("Failed to resolve handle: {}", error))), + } + } +} + +fn reserve_loopback_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .map_err(|error| AppError::io(format!("Failed to reserve AT Protocol callback port: {}", error)))?; + listener + .local_addr() + .map(|addr| addr.port()) + .map_err(|error| AppError::io(format!("Failed to inspect AT Protocol callback port: {}", error))) } #[cfg(test)] diff --git a/src-tauri/src/atproto/mod.rs b/src-tauri/src/atproto/mod.rs index 45baf56..643cffc 100644 --- a/src-tauri/src/atproto/mod.rs +++ b/src-tauri/src/atproto/mod.rs @@ -1,3 +1,5 @@ pub mod auth; +pub mod strings; pub use auth::{AtProtoState, SessionInfo}; +pub use strings::StringRecord; diff --git a/src-tauri/src/atproto/strings.rs b/src-tauri/src/atproto/strings.rs new file mode 100644 index 0000000..54824a8 --- /dev/null +++ b/src-tauri/src/atproto/strings.rs @@ -0,0 +1,116 @@ +use jacquard::api::com_atproto::repo::get_record::GetRecord; +use jacquard::api::com_atproto::repo::list_records::ListRecords; +use jacquard::api::sh_tangled::string::TangledString; +use jacquard::common::types::collection::Collection; +use jacquard::common::types::recordkey::RecordKey; +use jacquard::common::types::value::from_data; +use jacquard::common::xrpc::XrpcExt; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StringRecord { + pub uri: String, + pub tid: String, + pub filename: String, + pub description: String, + pub contents: String, + pub created_at: String, +} + +impl StringRecord { + pub fn from_tangled_string(uri: &str, value: TangledString<'_>) -> Option { + let tid = jacquard::common::types::string::AtUri::new(uri) + .ok()? + .rkey() + .map(|rkey| rkey.as_ref().to_string())?; + + Some(Self { + uri: uri.to_string(), + tid, + filename: value.filename.to_string(), + description: value.description.to_string(), + contents: value.contents.to_string(), + created_at: value.created_at.to_string(), + }) + } +} + +impl super::auth::AtProtoState { + pub async fn string_list(&self, did_or_handle: &str) -> Result, writer_core::AppError> { + let (repo_did, pds_url) = self.resolve_repo_and_pds(did_or_handle).await?; + let request = ListRecords::new() + .repo(jacquard::common::types::ident::AtIdentifier::Did(repo_did)) + .collection(TangledString::nsid()) + .limit(Some(100)) + .reverse(Some(true)) + .build(); + + let response = self + .http + .xrpc(pds_url) + .send(&request) + .await + .map_err(|error| writer_core::AppError::io(format!("Failed to list Tangled strings: {}", error)))?; + let output = response + .into_output() + .map_err(|error| writer_core::AppError::io(format!("Failed to decode Tangled strings: {}", error)))?; + + let mut records = Vec::with_capacity(output.records.len()); + for record in output.records { + let value = from_data::>(&record.value) + .map_err(|error| writer_core::AppError::io(format!("Failed to parse Tangled string: {}", error)))?; + let Some(mapped) = StringRecord::from_tangled_string(record.uri.as_ref(), value) else { + return Err(writer_core::AppError::new( + writer_core::ErrorCode::Parse, + "Failed to derive Tangled string record key from URI", + )); + }; + records.push(mapped); + } + + Ok(records) + } + + pub async fn string_get(&self, did_or_handle: &str, tid: &str) -> Result { + let trimmed_tid = tid.trim(); + if trimmed_tid.is_empty() { + return Err(writer_core::AppError::new( + writer_core::ErrorCode::InvalidPath, + "String ID is required", + )); + } + + let (repo_did, pds_url) = self.resolve_repo_and_pds(did_or_handle).await?; + let rkey = RecordKey::any(trimmed_tid).map_err(|error| { + writer_core::AppError::new( + writer_core::ErrorCode::Parse, + format!("Invalid Tangled string ID: {}", error), + ) + })?; + let request = GetRecord::new() + .repo(jacquard::common::types::ident::AtIdentifier::Did(repo_did)) + .collection(TangledString::nsid()) + .rkey(rkey) + .build(); + + let response = self + .http + .xrpc(pds_url) + .send(&request) + .await + .map_err(|error| writer_core::AppError::io(format!("Failed to fetch Tangled string: {}", error)))?; + let output = response + .into_output() + .map_err(|error| writer_core::AppError::io(format!("Failed to decode Tangled string: {}", error)))?; + let value = from_data::>(&output.value) + .map_err(|error| writer_core::AppError::io(format!("Failed to parse Tangled string: {}", error)))?; + + StringRecord::from_tangled_string(output.uri.as_ref(), value).ok_or_else(|| { + writer_core::AppError::new( + writer_core::ErrorCode::Parse, + "Failed to derive Tangled string record key from URI", + ) + }) + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7211c91..b921f50 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,4 @@ -use super::atproto::{AtProtoState, SessionInfo}; +use super::atproto::{AtProtoState, SessionInfo, StringRecord}; use super::capture; use super::locations::*; use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; @@ -64,6 +64,34 @@ pub async fn atproto_session_status(state: State<'_, AppState>) -> CommandRespon Ok(CommandResult::ok(state.atproto.session_status().await)) } +#[tauri::command] +pub async fn string_list(state: State<'_, AppState>, did_or_handle: String) -> CommandResponse> { + log::info!("Listing Tangled strings"); + + match state.atproto.string_list(&did_or_handle).await { + Ok(records) => Ok(CommandResult::ok(records)), + Err(error) => { + log::error!("Failed to list Tangled strings: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn string_get( + state: State<'_, AppState>, did_or_handle: String, tid: String, +) -> CommandResponse { + log::info!("Fetching Tangled string"); + + match state.atproto.string_get(&did_or_handle, &tid).await { + Ok(record) => Ok(CommandResult::ok(record)), + Err(error) => { + log::error!("Failed to fetch Tangled string: {}", error); + Ok(CommandResult::err(error)) + } + } +} + #[tauri::command] pub fn app_version_get() -> CommandResponse { Ok(CommandResult::ok( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3247126..21a1544 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -90,6 +90,8 @@ pub fn run() { cmd::atproto_login, cmd::atproto_logout, cmd::atproto_session_status, + cmd::string_list, + cmd::string_get, cmd::app_version_get, cmd::location_add_via_dialog, cmd::location_list, diff --git a/src/App.tsx b/src/App.tsx index 60d98b9..ba325ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,13 +33,7 @@ const AppContent = ({ isFocusMode, view }: { isFocusMode: boolean; view: Workspa return ( <> - + @@ -54,13 +48,7 @@ const AppContent = ({ isFocusMode, view }: { isFocusMode: boolean; view: Workspa atProtoPending={atProto.isPending} onOpenAtProtoAuth={atProto.openAuthSheet} onLogoutAtProto={atProto.handleLogout} /> - + { expect(atprotoLogout(onOk, onErr)).toMatchObject({ type: "Invoke", command: "atproto_logout", payload: {} }); }); + + it("should create Tangled string list and get commands", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + + expect(stringList("alice.bsky.social", onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "string_list", + payload: { didOrHandle: "alice.bsky.social" }, + }); + + expect(stringGet("alice.bsky.social", "3lxyz", onOk, onErr)).toMatchObject({ + type: "Invoke", + command: "string_get", + payload: { didOrHandle: "alice.bsky.social", tid: "3lxyz" }, + }); + }); }); describe(startWatch, () => { @@ -503,6 +522,35 @@ describe(runCmd, () => { }); expect(onErr).not.toHaveBeenCalled(); }); + + it("normalizes Tangled string records", async () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + + vi.mocked(invoke).mockResolvedValueOnce({ + type: "ok", + value: [{ + uri: "at://did:plc:alice/sh.tangled.string/3lxyz", + tid: "3lxyz", + filename: "notes.py", + description: "Snippet", + contents: "print('hi')", + created_at: "2026-03-19T10:00:00Z", + }], + }); + + await runCmd(stringList("alice.bsky.social", onOk, onErr)); + + expect(onOk).toHaveBeenCalledWith([{ + uri: "at://did:plc:alice/sh.tangled.string/3lxyz", + tid: "3lxyz", + filename: "notes.py", + description: "Snippet", + contents: "print('hi')", + createdAt: "2026-03-19T10:00:00Z", + }]); + expect(onErr).not.toHaveBeenCalled(); + }); }); describe("batch commands", () => { diff --git a/src/__tests__/useAtProtoController.test.tsx b/src/__tests__/useAtProtoController.test.tsx new file mode 100644 index 0000000..f130a67 --- /dev/null +++ b/src/__tests__/useAtProtoController.test.tsx @@ -0,0 +1,43 @@ +import { toImportMarkdown } from "$hooks/controllers/useAtProtoController"; +import { describe, expect, it } from "vitest"; + +describe("toImportMarkdown", () => { + it("passes markdown strings through unchanged", () => { + expect( + toImportMarkdown({ + uri: "at://did:plc:alice/sh.tangled.string/1", + tid: "1", + filename: "notes.md", + description: "", + contents: "# Hello", + createdAt: "2026-03-19T10:00:00Z", + }), + ).toBe("# Hello"); + }); + + it("wraps code strings in fenced code blocks", () => { + expect( + toImportMarkdown({ + uri: "at://did:plc:alice/sh.tangled.string/2", + tid: "2", + filename: "script.py", + description: "", + contents: "print('hi')", + createdAt: "2026-03-19T10:00:00Z", + }), + ).toBe("```py\nprint('hi')\n```\n"); + }); + + it("uses a longer fence when the source already contains triple backticks", () => { + expect( + toImportMarkdown({ + uri: "at://did:plc:alice/sh.tangled.string/3", + tid: "3", + filename: "script.ts", + description: "", + contents: "const fence = '```';", + createdAt: "2026-03-19T10:00:00Z", + }), + ).toBe("````ts\nconst fence = '```';\n````\n"); + }); +}); diff --git a/src/components/AtProto/AtProtoAuthSheet.tsx b/src/components/AtProto/AtProtoAuthSheet.tsx index 27e8406..0bd6e4f 100644 --- a/src/components/AtProto/AtProtoAuthSheet.tsx +++ b/src/components/AtProto/AtProtoAuthSheet.tsx @@ -1,94 +1,135 @@ import { Button } from "$components/Button"; import { Sheet } from "$components/Sheet"; +import type { useAtProtoController } from "$hooks/controllers/useAtProtoController"; +import { FileTypeIcon, Tangled } from "$icons"; import type { AtProtoSheetMode } from "$state/types"; -import type { AtProtoSession } from "$types"; -import { type ChangeEventHandler, type KeyboardEventHandler, useCallback, useMemo, useState } from "react"; - -type AtProtoAuthSheetProps = { - mode: AtProtoSheetMode; - session: AtProtoSession | null; - isPending: boolean; - onClose: () => void; - onLogin: (handle: string) => void; - onLogout: () => void; -}; - -function AuthSheetHeader({ title, description }: { title: string; description: string }) { +import type { TangledStringRecord } from "$types"; +import { type ChangeEventHandler, type KeyboardEventHandler, useCallback, useMemo } from "react"; + +type Controller = ReturnType; + +type AtProtoAuthSheetProps = { controller: Controller }; + +function AuthSheetHeader( + { title, description, mode, onBack }: { + title: string; + description: string; + mode: AtProtoSheetMode; + onBack?: () => void; + }, +) { return (
-

{title}

-

{description}

+
+
+ +

{description}

+
+ {mode === "import" && onBack && } +
); } -function LoginView({ isPending, onLogin }: { isPending: boolean; onLogin: (handle: string) => void }) { - const [handle, setHandle] = useState(""); - - const isDisabled = useMemo(() => isPending || !handle.trim(), [handle, isPending]); - const submit = useCallback(() => { - onLogin(handle); - }, [handle, onLogin]); - const handleChange: ChangeEventHandler = useCallback((event) => { - setHandle(event.target.value); - }, []); - const handleKeyDown: KeyboardEventHandler = useCallback((event) => { +function HeaderTitle({ title }: { title: string }) { + return ( +
+ +

{title}

+
+ ); +} + +function LoginActions({ controller, isDisabled }: { controller: Controller; isDisabled: boolean }) { + const handleConnect = useCallback(() => { + controller.handleLogin(controller.importState.handle); + }, [controller]); + + return ( +
+

+ Writer opens your browser and completes the OAuth loopback flow locally. +

+
+ + +
+
+ ); +} + +function LoginView({ controller }: { controller: Controller }) { + const isDisabled = useMemo(() => controller.isPending || !controller.importState.handle.trim(), [ + controller.importState.handle, + controller.isPending, + ]); + const handleChange = useCallback>((event) => { + controller.setImportHandle(event.target.value); + }, [controller]); + const handleKeyDown = useCallback>((event) => { if (event.key === "Enter" && !isDisabled) { - submit(); + controller.handleLogin(controller.importState.handle); } - }, [isDisabled, submit]); + }, [controller, isDisabled]); return ( <> + description="Sign in with your AT Protocol handle to browse and import Tangled strings." />
-
-

- Writer opens your browser and completes the OAuth loopback flow locally. -

- -
+
); } -function SessionView( - { session, isPending, onLogout }: { session: AtProtoSession | null; isPending: boolean; onLogout: () => void }, -) { +function SessionAccountCard({ controller }: { controller: Controller }) { + return ( +
+
+ + Account +
+
{controller.session?.handle ?? "Unknown"}
+
{controller.session?.did ?? "Unknown DID"}
+
+ ); +} + +function SessionView({ controller }: { controller: Controller }) { return ( <>
-
-
Account
-
{session?.handle ?? "Unknown handle"}
-
{session?.did ?? "Unknown DID"}
-
+
PDS endpoint
-
{session?.endpoint ?? "Unknown endpoint"}
+
+ {controller.session?.endpoint ?? "Unknown endpoint"} +
-
- +
@@ -96,20 +137,216 @@ function SessionView( ); } -export function AtProtoAuthSheet({ mode, session, isPending, onClose, onLogin, onLogout }: AtProtoAuthSheetProps) { +function RecordRow( + { record, isSelected, onSelectTid }: { + record: TangledStringRecord; + isSelected: boolean; + onSelectTid: (tid: string) => void; + }, +) { + const handleClick = useCallback(() => { + onSelectTid(record.tid); + }, [onSelectTid, record.tid]); + + return ( + + ); +} + +function BrowseHandleForm({ controller }: { controller: Controller }) { + const handleChange = useCallback>((event) => { + controller.setImportHandle(event.target.value); + }, [controller]); + + return ( +
+ +

+ Public strings can be imported without signing in. The browser defaults to your connected handle when available. +

+
+ ); +} + +function RecordsPanel({ controller }: { controller: Controller }) { + const handleSelectTid = useCallback((tid: string) => { + controller.handleSelectString(tid); + }, [controller]); + + return ( +
+
+ {controller.importState.browseHandle ? `Strings for ${controller.importState.browseHandle}` : "Strings"} +
+
+ {controller.importState.records.length === 0 + ? ( +
+ {controller.importState.isListing + ? "Loading Tangled strings..." + : "No strings loaded yet. Enter a handle and browse."} +
+ ) + : controller.importState.records.map((record) => ( + + ))} +
+
+ ); +} + +function ImportDestinationForm({ controller }: { controller: Controller }) { + const handleLocationChange = useCallback>((event) => { + controller.setDestinationLocationId(Number(event.target.value) || null); + }, [controller]); + const handlePathChange = useCallback>((event) => { + controller.setDestinationRelPath(event.target.value); + }, [controller]); + const handleImport = useCallback(() => { + void controller.handleImport(); + }, [controller]); + const importDisabled = controller.importState.isSaving + || !controller.importState.selectedRecord + || !controller.importState.destinationLocationId + || !controller.importState.destinationRelPath.trim() + || !controller.hasLocations; + + return ( +
+ + +

+ Non-Markdown strings are imported as fenced code blocks so the resulting document stays readable in Writer. +

+
+ +
+
+ ); +} + +function PreviewPanel({ controller }: { controller: Controller }) { + return ( +
+
+ Preview +
+
+        {controller.importState.isFetching
+          ? "Loading string preview..."
+          : controller.importState.previewText || "Select a string to preview the imported document body."}
+      
+
+ ); +} + +function SelectedRecordSummary({ controller }: { controller: Controller }) { + const selectedFilename = controller.importState.selectedRecord?.filename ?? "Nothing selected"; + + return ( +
+
{selectedFilename}
+

+ {controller.importState.selectedRecord?.description || "Select a string to inspect its contents before import."} +

+
+ ); +} + +function ImportView({ controller }: { controller: Controller }) { + return ( + <> + +
+
+ + +
+
+ + + +
+
+ + ); +} + +export function AtProtoAuthSheet({ controller }: AtProtoAuthSheetProps) { return (
- {mode === "session" - ? - : } + {controller.sheetMode === "session" && } + {controller.sheetMode === "import" && } + {controller.sheetMode === "login" && }
); diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx index 2f651bd..d56bfc9 100644 --- a/src/components/Toolbar/Toolbar.tsx +++ b/src/components/Toolbar/Toolbar.tsx @@ -152,9 +152,8 @@ export function Toolbar(
+ label={atProtoSession ? `${atProtoSession.handle}` : "Login"} + onClick={onAtProtoAuth} /> {onExportPdf && ( ; +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 87185c2..e617e99 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1,3 +1,6 @@ +import { FileTypeIcon as KFileTypeIcon } from "./file-icon"; +export { getRecordIconClass } from "./file-icon"; + export type IconSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; const sizeClass = (size: IconSize) => { @@ -223,6 +226,12 @@ export const AtSignIcon = ({ size = "md", className = "", style }: IconProps) => ); +export const FileTypeIcon = ({ size = "md", className = "", style, filename }: IconProps & { filename: string }) => ( + + + +); + /** * @see assets/dolly.svg */ diff --git a/src/hooks/controllers/useAtProtoController.ts b/src/hooks/controllers/useAtProtoController.ts index 2950247..fdd2921 100644 --- a/src/hooks/controllers/useAtProtoController.ts +++ b/src/hooks/controllers/useAtProtoController.ts @@ -1,11 +1,97 @@ -import { atprotoLogin, atprotoLogout, atprotoSessionStatus, runCmd } from "$ports"; +import { + atprotoLogin, + atprotoLogout, + atprotoSessionStatus, + docExists, + docSave, + runCmd, + stringGet, + stringList, +} from "$ports"; import { useAtProtoUiState } from "$state/selectors"; import { showErrorToast, showSuccessToast } from "$state/stores/toasts"; +import type { LocationDescriptor, TangledStringRecord } from "$types"; import { f } from "$utils/serialize"; import * as logger from "@tauri-apps/plugin-log"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -export function useAtProtoController() { +const PLAINTEXT_EXTENSIONS = new Set(["md", "markdown", "mdown", "txt", "text"]); + +function getFileExtension(filename: string): string | null { + const basename = filename.trim().split("/").pop() ?? ""; + const dotIndex = basename.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === basename.length - 1) { + return null; + } + + return basename.slice(dotIndex + 1).toLowerCase(); +} + +function getLanguageTag(extension: string | null): string { + switch (extension) { + case "yml": + return "yaml"; + default: + return extension ?? ""; + } +} + +function buildFence(contents: string): string { + let fence = "```"; + while (contents.includes(fence)) { + fence += "`"; + } + return fence; +} + +export function toImportMarkdown(record: TangledStringRecord | null): string { + if (!record) { + return ""; + } + + const extension = getFileExtension(record.filename); + if (!extension || PLAINTEXT_EXTENSIONS.has(extension)) { + return record.contents; + } + + const language = getLanguageTag(extension); + const fence = buildFence(record.contents); + return `${fence}${language}\n${record.contents}\n${fence}\n`; +} + +function normalizeImportPath(value: string): string { + return value.trim().replaceAll("\\", "/").replace(/^\/+/, ""); +} + +function getDefaultLocationId(locations: LocationDescriptor[], selectedLocationId?: number): number | null { + if (selectedLocationId && locations.some((location) => location.id === selectedLocationId)) { + return selectedLocationId; + } + + return locations[0]?.id ?? null; +} + +type UseAtProtoControllerOptions = { + locations: LocationDescriptor[]; + selectedLocationId?: number; + refreshSidebar: (locationId?: number) => void; +}; + +type ImportState = { + handle: string; + browseHandle: string; + records: TangledStringRecord[]; + selectedTid: string | null; + selectedRecord: TangledStringRecord | null; + destinationLocationId: number | null; + destinationRelPath: string; + previewText: string; + isListing: boolean; + isFetching: boolean; + isSaving: boolean; +}; + +export function useAtProtoController({ locations, selectedLocationId, refreshSidebar }: UseAtProtoControllerOptions) { const { sheetMode, session, @@ -13,11 +99,24 @@ export function useAtProtoController() { isPending, openLoginSheet, openSessionSheet, + openImportSheet: openImportSheetState, closeSheet, setSession, setHydrated, setPending, } = useAtProtoUiState(); + const [importHandle, setImportHandle] = useState(""); + const [browseHandle, setBrowseHandle] = useState(""); + const [records, setRecords] = useState([]); + const [selectedTid, setSelectedTid] = useState(null); + const [selectedRecord, setSelectedRecord] = useState(null); + const [destinationLocationId, setDestinationLocationId] = useState(null); + const [destinationRelPath, setDestinationRelPath] = useState(""); + const [isListing, setIsListing] = useState(false); + const [isFetching, setIsFetching] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const listRequestIdRef = useRef(0); + const getRequestIdRef = useRef(0); useEffect(() => { let isCancelled = false; @@ -29,6 +128,9 @@ export function useAtProtoController() { setSession(nextSession); setHydrated(true); + if (nextSession && !importHandle.trim()) { + setImportHandle(nextSession.handle); + } }, (error) => { if (isCancelled) { return; @@ -42,7 +144,15 @@ export function useAtProtoController() { return () => { isCancelled = true; }; - }, [setHydrated, setSession]); + }, [importHandle, setHydrated, setSession]); + + useEffect(() => { + if (destinationLocationId !== null && locations.some((location) => location.id === destinationLocationId)) { + return; + } + + setDestinationLocationId(getDefaultLocationId(locations, selectedLocationId)); + }, [destinationLocationId, locations, selectedLocationId]); const openAuthSheet = useCallback(() => { if (session) { @@ -53,6 +163,14 @@ export function useAtProtoController() { openLoginSheet(); }, [openLoginSheet, openSessionSheet, session]); + const openImportSheet = useCallback(() => { + setImportHandle((currentHandle) => currentHandle.trim() ? currentHandle : session?.handle ?? ""); + setDestinationLocationId((currentLocationId) => + currentLocationId ?? getDefaultLocationId(locations, selectedLocationId) + ); + openImportSheetState(); + }, [locations, openImportSheetState, selectedLocationId, session?.handle]); + const handleLogin = useCallback((handle: string) => { const trimmedHandle = handle.trim(); if (!trimmedHandle || isPending) { @@ -63,6 +181,7 @@ export function useAtProtoController() { void runCmd(atprotoLogin(trimmedHandle, (nextSession) => { setPending(false); setSession(nextSession); + setImportHandle(nextSession.handle); openSessionSheet(); showSuccessToast(`Connected to Tangled as ${nextSession.handle}`); }, (error) => { @@ -90,16 +209,183 @@ export function useAtProtoController() { })); }, [closeSheet, isPending, setPending, setSession]); + const handleSelectString = useCallback((tid: string, ownerHandle = browseHandle) => { + const trimmedTid = tid.trim(); + const trimmedOwner = ownerHandle.trim(); + if (!trimmedTid || !trimmedOwner) { + return; + } + + setSelectedTid(trimmedTid); + setIsFetching(true); + const requestId = getRequestIdRef.current + 1; + getRequestIdRef.current = requestId; + + void runCmd(stringGet(trimmedOwner, trimmedTid, (record) => { + if (getRequestIdRef.current !== requestId) { + return; + } + + setIsFetching(false); + setSelectedRecord(record); + setDestinationRelPath(record.filename); + }, (error) => { + if (getRequestIdRef.current !== requestId) { + return; + } + + setIsFetching(false); + logger.error(f("Failed to load Tangled string", { browseHandle: trimmedOwner, tid: trimmedTid, error })); + showErrorToast(error.message); + })); + }, [browseHandle]); + + const handleBrowseStrings = useCallback(() => { + const trimmedHandle = importHandle.trim(); + if (!trimmedHandle || isListing) { + return; + } + + setIsListing(true); + setBrowseHandle(trimmedHandle); + const requestId = listRequestIdRef.current + 1; + listRequestIdRef.current = requestId; + + void runCmd(stringList(trimmedHandle, (nextRecords) => { + if (listRequestIdRef.current !== requestId) { + return; + } + + setIsListing(false); + setRecords(nextRecords); + + if (nextRecords.length === 0) { + setSelectedTid(null); + setSelectedRecord(null); + setDestinationRelPath(""); + return; + } + + const nextSelectedTid = nextRecords.some((record) => record.tid === selectedTid) + ? selectedTid + : nextRecords[0]?.tid ?? null; + + if (!nextSelectedTid) { + return; + } + + handleSelectString(nextSelectedTid, trimmedHandle); + }, (error) => { + if (listRequestIdRef.current !== requestId) { + return; + } + + setIsListing(false); + logger.error(f("Failed to list Tangled strings", { handle: trimmedHandle, error })); + showErrorToast(error.message); + })); + }, [handleSelectString, importHandle, isListing, selectedTid]); + + const handleImport = useCallback(async () => { + if (isSaving || !selectedRecord || !destinationLocationId) { + return; + } + + const relPath = normalizeImportPath(destinationRelPath); + if (!relPath) { + showErrorToast("Choose a destination path before importing."); + return; + } + + setIsSaving(true); + const targetContents = toImportMarkdown(selectedRecord); + + const alreadyExists = await new Promise((resolve) => { + void runCmd(docExists(destinationLocationId, relPath, (exists) => { + resolve(exists); + }, (error) => { + logger.error(f("Failed to check import destination", { destinationLocationId, relPath, error })); + showErrorToast(error.message); + resolve(true); + })); + }); + + if (alreadyExists) { + setIsSaving(false); + showErrorToast(`A document already exists at ${relPath}. Choose a different path.`); + return; + } + + const saved = await new Promise((resolve) => { + void runCmd(docSave(destinationLocationId, relPath, targetContents, (result) => { + resolve(result.success); + }, (error) => { + logger.error( + f("Failed to import Tangled string", { destinationLocationId, relPath, tid: selectedRecord.tid, error }), + ); + showErrorToast(error.message); + resolve(false); + })); + }); + + setIsSaving(false); + if (!saved) { + return; + } + + refreshSidebar(destinationLocationId); + closeSheet(); + showSuccessToast(`Imported ${selectedRecord.filename} into ${relPath}`); + }, [closeSheet, destinationLocationId, destinationRelPath, isSaving, refreshSidebar, selectedRecord]); + + const importState = useMemo( + () => ({ + handle: importHandle, + browseHandle, + records, + selectedTid, + selectedRecord, + destinationLocationId, + destinationRelPath, + previewText: toImportMarkdown(selectedRecord), + isListing, + isFetching, + isSaving, + }), + [ + browseHandle, + destinationLocationId, + destinationRelPath, + importHandle, + isFetching, + isListing, + isSaving, + records, + selectedRecord, + selectedTid, + ], + ); + return { sheetMode, session, isHydrated, isPending, + locations, + importState, openAuthSheet, openLoginSheet, openSessionSheet, + openImportSheet, closeSheet, handleLogin, handleLogout, + handleBrowseStrings, + handleSelectString, + handleImport, + setImportHandle, + setDestinationLocationId, + setDestinationRelPath, + hasLocations: locations.length > 0, }; } diff --git a/src/hooks/controllers/useWorkspaceViewController.ts b/src/hooks/controllers/useWorkspaceViewController.ts index e6bcdfc..5cb1695 100644 --- a/src/hooks/controllers/useWorkspaceViewController.ts +++ b/src/hooks/controllers/useWorkspaceViewController.ts @@ -59,7 +59,6 @@ export function useWorkspaceViewController(): WorkspaceViewController { const { model: editorModel, dispatch: editorDispatch, openDoc } = useEditor(); const { model: previewModel, render: renderPreview, syncLine: syncPreviewLine, setDoc: setPreviewDoc } = usePreview(); const exportPdf = usePdfExport(); - const atProto = useAtProtoController(); useWorkspaceSync(); useLayoutHotkeys(); @@ -84,6 +83,7 @@ export function useWorkspaceViewController(): WorkspaceViewController { handleCreateNewDocument, handleRefreshSidebar, } = useWorkspaceController(); + const atProto = useAtProtoController({ locations, selectedLocationId, refreshSidebar: handleRefreshSidebar }); const { handleSave, handleNewDocument } = useDocumentActions({ editorDocRef: editorModel.docRef, diff --git a/src/ports/commands.ts b/src/ports/commands.ts index 33f1590..e79e5cf 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -12,6 +12,7 @@ import type { RenderResult, SearchHit, SessionState, + TangledStringRecord, } from "$types"; import { info } from "@tauri-apps/plugin-log"; import { invokeCmd, runCmd } from "./invoke"; @@ -56,6 +57,8 @@ import type { SessionReorderTabsParams, SessionTabIdParams, SessionUpdateTabDocParams, + StringGetParams, + StringListParams, StyleCheckScanParams, StyleCheckSetParams, UiLayoutSetParams, @@ -148,6 +151,14 @@ export function atprotoLogout(...[onOk, onErr]: LocParams): Cmd { return invokeCmd("atproto_logout", {}, onOk, onErr); } +export function stringList(...[didOrHandle, onOk, onErr]: StringListParams): Cmd { + return invokeCmd("string_list", { didOrHandle }, onOk, onErr); +} + +export function stringGet(...[didOrHandle, tid, onOk, onErr]: StringGetParams): Cmd { + return invokeCmd("string_get", { didOrHandle, tid }, onOk, onErr); +} + export function locationAddViaDialog(...[onOk, onErr]: LocParams): Cmd { return invokeCmd("location_add_via_dialog", {}, onOk, onErr); } diff --git a/src/ports/invoke.ts b/src/ports/invoke.ts index 18147e0..17a59f8 100644 --- a/src/ports/invoke.ts +++ b/src/ports/invoke.ts @@ -10,6 +10,7 @@ import type { GlobalCaptureSettings, SearchHit, SessionState, + TangledStringRecord, } from "$types"; import { f } from "$utils/serialize"; import type { InvokeArgs } from "@tauri-apps/api/core"; @@ -256,6 +257,25 @@ function normalizeAtProtoSession(value: unknown): AtProtoSession | null { return { did: value.did, handle: value.handle, sessionId: value.session_id, endpoint: value.endpoint }; } +function normalizeTangledStringRecord(value: unknown): TangledStringRecord { + if (!isRecord(value)) { + return { uri: "", tid: "", filename: "", description: "", contents: "", createdAt: "" }; + } + + return { + uri: typeof value.uri === "string" ? value.uri : "", + tid: typeof value.tid === "string" ? value.tid : "", + filename: typeof value.filename === "string" ? value.filename : "", + description: typeof value.description === "string" ? value.description : "", + contents: typeof value.contents === "string" ? value.contents : "", + createdAt: typeof value.createdAt === "string" + ? value.createdAt + : typeof value.created_at === "string" + ? value.created_at + : "", + }; +} + function normalizeSessionState(value: unknown): SessionState { if (!isRecord(value) || !Array.isArray(value.tabs)) { return { tabs: [], activeTabId: null }; @@ -329,6 +349,16 @@ function normalizeCommandValue(command: string, value: unknown): unknown { case "atproto_session_status": { return normalizeAtProtoSession(value); } + case "string_get": { + return normalizeTangledStringRecord(value); + } + case "string_list": { + if (!Array.isArray(value)) { + return []; + } + + return value.map((record) => normalizeTangledStringRecord(record)); + } case "style_check_scan": { if (!Array.isArray(value)) { return []; diff --git a/src/ports/types.ts b/src/ports/types.ts index c47b941..b71fd56 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -20,6 +20,7 @@ import type { SessionState, StyleCheckPattern, StyleMarkerStyle, + TangledStringRecord, } from "$types"; export type EditorState = { @@ -265,3 +266,9 @@ export type AtProtoSessionStatusParams = LocParams; export type AtProtoLoginParams = Parameters< (handle: string, onOk: SuccessCallback, onErr: ErrorCallback) => void >; +export type StringListParams = Parameters< + (didOrHandle: string, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; +export type StringGetParams = Parameters< + (didOrHandle: string, tid: string, onOk: SuccessCallback, onErr: ErrorCallback) => void +>; diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 62cfe3e..e56240c 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -506,6 +506,7 @@ export const useAtProtoUiState = () => isPending: state.atProtoPending, openLoginSheet: state.openAtProtoLoginSheet, openSessionSheet: state.openAtProtoSessionSheet, + openImportSheet: state.openAtProtoImportSheet, closeSheet: state.closeAtProtoSheet, setSession: state.setAtProtoSession, setHydrated: state.setAtProtoHydrated, diff --git a/src/state/stores/ui.ts b/src/state/stores/ui.ts index 5264ac8..7740845 100644 --- a/src/state/stores/ui.ts +++ b/src/state/stores/ui.ts @@ -79,6 +79,7 @@ export const useUiStore = create()((set, get) => ({ toggleStyleDiagnostics: () => set((state) => ({ styleDiagnosticsOpen: !state.styleDiagnosticsOpen })), openAtProtoLoginSheet: () => set({ atProtoSheetMode: "login" }), openAtProtoSessionSheet: () => set({ atProtoSheetMode: "session" }), + openAtProtoImportSheet: () => set({ atProtoSheetMode: "import" }), closeAtProtoSheet: () => set({ atProtoSheetMode: "closed" }), setAtProtoSession: (value) => set({ atProtoSession: value }), setAtProtoHydrated: (value) => set({ atProtoHydrated: value }), diff --git a/src/state/types.ts b/src/state/types.ts index 3ca9977..7a79aad 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -218,7 +218,7 @@ export type SearchActions = { resetSearch: () => void; }; -export type AtProtoSheetMode = "closed" | "login" | "session"; +export type AtProtoSheetMode = "closed" | "login" | "session" | "import"; export type AtProtoUiState = { atProtoSheetMode: AtProtoSheetMode; @@ -230,6 +230,7 @@ export type AtProtoUiState = { export type AtProtoUiActions = { openAtProtoLoginSheet: () => void; openAtProtoSessionSheet: () => void; + openAtProtoImportSheet: () => void; closeAtProtoSheet: () => void; setAtProtoSession: (value: AtProtoSession | null) => void; setAtProtoHydrated: (value: boolean) => void; diff --git a/src/types.ts b/src/types.ts index e3162e3..cf833e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,14 @@ export type SearchHit = { export type AppError = { code: ErrorCode; message: string; context?: string }; export type AtProtoSession = { did: string; handle: string; sessionId: string; endpoint: string }; +export type TangledStringRecord = { + uri: string; + tid: string; + filename: string; + description: string; + contents: string; + createdAt: string; +}; export type FocusDimmingMode = "off" | "sentence" | "paragraph"; export type MarkdownPreviewStyle = "github" | "pdf";