maarcadetweet: initial commit
AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/tauri.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>maarcadetweet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2517
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "maarcadetweet-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"packageManager": "npm@10.8.2",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-notification": "^2.0.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.2",
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"svelte": "^5.2.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.0",
|
||||
"vite-plugin-static-copy": "^1.0.6"
|
||||
}
|
||||
}
|
||||
Generated
+6918
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "maarcadetweet-app"
|
||||
version = "0.1.0"
|
||||
description = "maarcadetweet Tauri desktop client"
|
||||
authors = ["EifelCloud"]
|
||||
edition = "2021"
|
||||
rust-version = "1.80"
|
||||
|
||||
[lib]
|
||||
name = "maarcadetweet_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-keyring-store = "0.2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
at-crypto = { path = "../../at-crypto" }
|
||||
at-shared = { path = "../../at-shared" }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
parking_lot = "0.12"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 358 B |
Binary file not shown.
|
After Width: | Height: | Size: 854 B |
Binary file not shown.
|
After Width: | Height: | Size: 102 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
pub struct ApiPlaceholder;
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Thin HTTP client the Tauri commands use to talk to the AppView.
|
||||
//!
|
||||
//! All four methods return parsed JSON or a stringified error that the
|
||||
//! Tauri command layer surfaces to the Svelte frontend as the
|
||||
//! `Result::Err` payload.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
/// One post as returned by the AppView read API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostDto {
|
||||
pub uri: String,
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub rkey: String,
|
||||
pub collection: String,
|
||||
pub text: String,
|
||||
pub cid: String,
|
||||
#[serde(default)]
|
||||
pub parent_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
pub root_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
pub embed: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub langs: Vec<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimelineResponse {
|
||||
pub posts: Vec<PostDto>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileResponse {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub posts: Vec<PostDto>,
|
||||
pub followers: i64,
|
||||
pub following: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResponse {
|
||||
pub posts: Vec<PostDto>,
|
||||
pub q: String,
|
||||
}
|
||||
|
||||
/// `GET /api/post/{uri}` response. The server hands back the post plus
|
||||
/// its `parent_uri` and `root_uri` rows in one round trip so the UI can
|
||||
/// expand a thread without three sequential fetches.
|
||||
///
|
||||
/// `like_count` and `repost_count` are included when the server
|
||||
/// resolves a real post; they're `None` for the "not in index"
|
||||
/// sentinel response (where `post` is null). The AppView has no
|
||||
/// auth yet, so we don't get `viewer_liked` / `viewer_reposted`
|
||||
/// from the server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResponse {
|
||||
pub post: Option<PostDto>,
|
||||
pub thread: ThreadView,
|
||||
#[serde(default)]
|
||||
pub like_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub repost_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadView {
|
||||
pub parent: Option<PostDto>,
|
||||
pub root: Option<PostDto>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppViewClient {
|
||||
pub base_url: String,
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl AppViewClient {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/timeline/home?did=&limit=&cursor=`
|
||||
pub async fn fetch_timeline(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<TimelineResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/timeline/home", self.base_url))
|
||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send timeline request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: timeline home returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<TimelineResponse>()
|
||||
.await
|
||||
.context("appview: timeline home JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/profile/<handle>` — accepts `@handle` or `handle`, and
|
||||
/// accepts a bare DID (the path param is opaque to the server).
|
||||
/// For DIDs containing `:`, prefer [`Self::fetch_profile_by_did`]
|
||||
/// which uses the query-param form.
|
||||
pub async fn fetch_profile(&self, handle: &str) -> Result<ProfileResponse> {
|
||||
let trimmed = handle.trim_start_matches('@');
|
||||
// If it looks like a DID, prefer the query-param form so the
|
||||
// colons don't have to be URL-encoded in the path.
|
||||
if trimmed.starts_with("did:") {
|
||||
return self.fetch_profile_by_did(trimmed).await;
|
||||
}
|
||||
let url = format!("{}/api/profile/{}", self.base_url, trimmed);
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send profile request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: profile returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<ProfileResponse>()
|
||||
.await
|
||||
.context("appview: profile JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/profile?did=...` — safest way to fetch a profile by DID.
|
||||
pub async fn fetch_profile_by_did(&self, did: &str) -> Result<ProfileResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/profile", self.base_url))
|
||||
.query(&[("did", did)])
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send profile-by-did request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: profile-by-did returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<ProfileResponse>()
|
||||
.await
|
||||
.context("appview: profile-by-did JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/search?q=&limit=`
|
||||
pub async fn fetch_search(&self, q: &str, limit: u32) -> Result<SearchResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/search", self.base_url))
|
||||
.query(&[("q", q), ("limit", &limit.to_string())])
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send search request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: search returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<SearchResponse>()
|
||||
.await
|
||||
.context("appview: search JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/post/{uri}` — thread hydration in one round trip.
|
||||
///
|
||||
/// `uri` is the verbatim `at://...` URI. We can't paste it directly
|
||||
/// into the path because the `://` looks like a scheme separator
|
||||
/// to the URL parser; instead we percent-encode the whole URI and
|
||||
/// append it as a single path segment. The server's
|
||||
/// `axum::extract::Path<String>` decodes it back to the verbatim
|
||||
/// string.
|
||||
pub async fn fetch_post(&self, uri: &str) -> Result<ThreadResponse> {
|
||||
let encoded = percent_encode_path(uri);
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/post/{}", self.base_url, encoded))
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send post request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: post returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<ThreadResponse>()
|
||||
.await
|
||||
.context("appview: post JSON parse")
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode every byte of `s` for use as a URL path segment.
|
||||
/// `axum`'s path extractor will decode it back. We use this rather
|
||||
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
|
||||
/// URIs contain `://` which the URL parser mistakes for a scheme.
|
||||
fn percent_encode_path(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 3);
|
||||
for b in s.bytes() {
|
||||
// RFC 3986 unreserved characters plus a few safe ones we want
|
||||
// to leave alone. Encode everything else to be conservative.
|
||||
let is_unreserved = matches!(
|
||||
b,
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~'
|
||||
);
|
||||
if is_unreserved {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percent_encode_path_at_uri() {
|
||||
let s = "at://did:plc:abc/app.twi.post/3k2";
|
||||
let e = percent_encode_path(s);
|
||||
assert_eq!(
|
||||
e,
|
||||
"at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.twi.post%2F3k2"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthSession {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
pub text: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn status_pds() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"rev": 1234,
|
||||
"lag_ms": 1200,
|
||||
"did": "did:plc:abc123def456ghi789jkl"
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_login(handle: String, _password: String) -> Result<AuthSession, String> {
|
||||
Ok(AuthSession {
|
||||
did: "did:plc:placeholder".into(),
|
||||
handle,
|
||||
access_jwt: "stub.jwt.token".into(),
|
||||
refresh_jwt: "stub.refresh.jwt".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn post_create(text: String) -> Result<Post, String> {
|
||||
if text.is_empty() {
|
||||
return Err("text is empty".into());
|
||||
}
|
||||
Ok(Post {
|
||||
uri: "at://did:plc:placeholder/app.twi.post/3k2lmnop".into(),
|
||||
cid: "bafyreigd2j7tj4w3bvxgrm3l4xx7e2fnpwz5xv2eei6t7wzc4zqr4z".into(),
|
||||
text,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn timeline_home(cursor: Option<String>) -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!({
|
||||
"posts": [],
|
||||
"cursor": cursor,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
pub mod api;
|
||||
pub mod appview_client;
|
||||
pub mod pds_client;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
|
||||
use appview_client::AppViewClient;
|
||||
use pds_client::PdsHttpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AccountSession {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AppVersionResp {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn app_version() -> AppVersionResp {
|
||||
AppVersionResp {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pds_describe(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
state.pds.describe_server().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_register(
|
||||
state: tauri::State<'_, AppState>,
|
||||
handle: String,
|
||||
password: String,
|
||||
) -> Result<AccountSession, String> {
|
||||
let sess = state
|
||||
.pds
|
||||
.create_account(&handle, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = AccountSession {
|
||||
did: sess.did.clone(),
|
||||
handle: sess.handle.clone(),
|
||||
access_jwt: sess.access_jwt.clone(),
|
||||
refresh_jwt: sess.refresh_jwt.clone(),
|
||||
};
|
||||
state.store.save(&s);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_login(
|
||||
state: tauri::State<'_, AppState>,
|
||||
identifier: String,
|
||||
password: String,
|
||||
) -> Result<AccountSession, String> {
|
||||
let sess = state
|
||||
.pds
|
||||
.create_session(&identifier, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = AccountSession {
|
||||
did: sess.did.clone(),
|
||||
handle: sess.handle.clone(),
|
||||
access_jwt: sess.access_jwt.clone(),
|
||||
refresh_jwt: sess.refresh_jwt.clone(),
|
||||
};
|
||||
state.store.save(&s);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_refresh(state: tauri::State<'_, AppState>) -> Result<AccountSession, String> {
|
||||
let current = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "no session".to_string())?;
|
||||
let sess = state
|
||||
.pds
|
||||
.refresh_session(¤t.refresh_jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = AccountSession {
|
||||
did: sess.did.clone(),
|
||||
handle: sess.handle.clone(),
|
||||
access_jwt: sess.access_jwt.clone(),
|
||||
refresh_jwt: sess.refresh_jwt.clone(),
|
||||
};
|
||||
state.store.save(&s);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_logout(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
state.store.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn current_session(state: tauri::State<'_, AppState>) -> Result<Option<AccountSession>, String> {
|
||||
Ok(state.store.load())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn post_create(
|
||||
state: tauri::State<'_, AppState>,
|
||||
text: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let record = serde_json::json!({
|
||||
"text": text,
|
||||
"createdAt": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let resp = state
|
||||
.pds
|
||||
.create_record(&sess.did, "app.twi.post", record, &sess.access_jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": resp.uri,
|
||||
"cid": resp.cid,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn resolve_handle(
|
||||
state: tauri::State<'_, AppState>,
|
||||
handle: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
state.pds.resolve_handle(&handle).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Helper: split an `at://did/collection/rkey` URI into its
|
||||
/// `rkey` component. Returns an error string the Tauri command
|
||||
/// can surface directly to the Svelte frontend.
|
||||
fn rkey_from_uri(uri: &str) -> Result<String, String> {
|
||||
let rkey = uri
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.ok_or_else(|| format!("invalid uri: {uri}"))?
|
||||
.to_string();
|
||||
if rkey.is_empty() {
|
||||
return Err(format!("invalid uri (empty rkey): {uri}"));
|
||||
}
|
||||
Ok(rkey)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn like_post(
|
||||
state: tauri::State<'_, AppState>,
|
||||
subject_uri: String,
|
||||
subject_cid: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let resp = state
|
||||
.pds
|
||||
.create_like(
|
||||
&sess.did,
|
||||
&subject_uri,
|
||||
&subject_cid,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": resp.uri,
|
||||
"cid": resp.cid,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn unlike_post(
|
||||
state: tauri::State<'_, AppState>,
|
||||
like_uri: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let rkey = rkey_from_uri(&like_uri)?;
|
||||
let resp = state
|
||||
.pds
|
||||
.delete_record(
|
||||
&sess.did,
|
||||
"app.bsky.feed.like",
|
||||
&rkey,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"commit": resp.commit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repost_post(
|
||||
state: tauri::State<'_, AppState>,
|
||||
subject_uri: String,
|
||||
subject_cid: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
// Reposts share the like wire shape — the PDS hardcodes the
|
||||
// collection, but `feed.like.create` is the only endpoint that
|
||||
// does so. For reposts we go through the generic
|
||||
// `com.atproto.repo.createRecord` with a repost-shaped record
|
||||
// value.
|
||||
let record = serde_json::json!({
|
||||
"subject": {
|
||||
"uri": subject_uri,
|
||||
"cid": subject_cid,
|
||||
},
|
||||
"createdAt": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let resp = state
|
||||
.pds
|
||||
.create_record_with(&sess.did, "app.bsky.feed.repost", record, false, &sess.access_jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": resp.uri,
|
||||
"cid": resp.cid,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn unrepost_post(
|
||||
state: tauri::State<'_, AppState>,
|
||||
repost_uri: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let rkey = rkey_from_uri(&repost_uri)?;
|
||||
let resp = state
|
||||
.pds
|
||||
.delete_record(
|
||||
&sess.did,
|
||||
"app.bsky.feed.repost",
|
||||
&rkey,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"commit": resp.commit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn timeline_home(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::TimelineResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
state
|
||||
.appview
|
||||
.fetch_timeline(&did, cursor.as_deref(), lim)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn profile_get(
|
||||
state: tauri::State<'_, AppState>,
|
||||
handle: String,
|
||||
) -> Result<appview_client::ProfileResponse, String> {
|
||||
state
|
||||
.appview
|
||||
.fetch_profile(&handle)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn search(
|
||||
state: tauri::State<'_, AppState>,
|
||||
q: String,
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::SearchResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
state
|
||||
.appview
|
||||
.fetch_search(&q, lim)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn post_get(
|
||||
state: tauri::State<'_, AppState>,
|
||||
uri: String,
|
||||
) -> Result<appview_client::ThreadResponse, String> {
|
||||
state
|
||||
.appview
|
||||
.fetch_post(&uri)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn status_pds(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
let sess = state.store.load();
|
||||
Ok(serde_json::json!({
|
||||
"did": sess.as_ref().map(|s| s.did.clone()),
|
||||
"handle": sess.as_ref().map(|s| s.handle.clone()),
|
||||
"authenticated": sess.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `fetch_blob(did, cid)` — fetch raw blob bytes from the PDS for
|
||||
/// rendering image embeds. The Tauri shell does the HTTP call (rather
|
||||
/// than fetching via AppView) because blobs live on the user's PDS,
|
||||
/// not the AppView's indexer.
|
||||
#[tauri::command]
|
||||
async fn fetch_blob(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
cid: String,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
// Optionally pass the caller's JWT so authenticated PDSes (when
|
||||
// we enable that) receive the right token. Today `getBlob` is
|
||||
// unauthenticated so this is just `None`.
|
||||
let jwt = state.store.load().map(|s| s.access_jwt);
|
||||
state
|
||||
.pds
|
||||
.get_blob(&did, &cid, jwt.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fire a native OS notification with an optional click target. The
|
||||
/// payload is also broadcast as an `app://notification` event so the
|
||||
/// frontend can route to `url` on click (via a notification listener
|
||||
/// registered in JS — see `frontend/src/main.ts`).
|
||||
///
|
||||
/// Used by the frontend for high-activity bursts (timeline event rate
|
||||
/// spike) and on first-session "new post by followed user" demo
|
||||
/// hooks. The Rust side emits the event up-front so the click target
|
||||
/// is in flight even if the OS strips the underlying notification
|
||||
/// (some platforms don't propagate click events back to Tauri).
|
||||
#[tauri::command]
|
||||
async fn show_notification(
|
||||
app: tauri::AppHandle,
|
||||
title: String,
|
||||
body: String,
|
||||
url: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
// Show the OS notification. The `.show()` call internally uses
|
||||
// `tauri::async_runtime::spawn` so it never blocks the caller.
|
||||
app.notification()
|
||||
.builder()
|
||||
.title(&title)
|
||||
.body(&body)
|
||||
.show()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Broadcast the metadata to the frontend so it can handle click
|
||||
// routing (focus window + navigate) without needing a per-action
|
||||
// action-type registration on the Rust side.
|
||||
let _ = tauri::Emitter::emit(
|
||||
&app,
|
||||
"app://notification",
|
||||
serde_json::json!({
|
||||
"title": title,
|
||||
"body": body,
|
||||
"url": url,
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
|
||||
let pds_url = std::env::var("MAARCADETWEET_PDS_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2583".to_string());
|
||||
let appview_url = std::env::var("MAARCADETWEET_APPVIEW_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
||||
|
||||
let state = AppState {
|
||||
pds: PdsHttpClient::new(pds_url),
|
||||
appview: AppViewClient::new(appview_url),
|
||||
store: store::SessionStore::new(),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(state)
|
||||
.setup(|app| {
|
||||
tracing::info!("maarcadetweet starting up");
|
||||
|
||||
// Embed the tray icon at compile time. `include_image!`
|
||||
// resolves paths relative to `CARGO_MANIFEST_DIR` and
|
||||
// bakes the raw RGBA pixels into the binary, so the
|
||||
// tray works regardless of the runtime CWD.
|
||||
const TRAY_ICON: tauri::image::Image<'static> =
|
||||
tauri::include_image!("icons/32x32.png");
|
||||
|
||||
let show_item = tauri::menu::MenuItem::with_id(
|
||||
app,
|
||||
"tray_show",
|
||||
"Show maarcadetweet",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("failed to build show menu item: {e}"))?;
|
||||
let compose_item = tauri::menu::MenuItem::with_id(
|
||||
app,
|
||||
"tray_compose",
|
||||
"Compose",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("failed to build compose menu item: {e}"))?;
|
||||
let quit_item = tauri::menu::MenuItem::with_id(
|
||||
app,
|
||||
"tray_quit",
|
||||
"Quit",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("failed to build quit menu item: {e}"))?;
|
||||
let separator = tauri::menu::PredefinedMenuItem::separator(app)
|
||||
.map_err(|e| format!("failed to build separator: {e}"))?;
|
||||
|
||||
let tray_menu = tauri::menu::Menu::with_items(
|
||||
app,
|
||||
&[&show_item, &compose_item, &separator, &quit_item],
|
||||
)
|
||||
.map_err(|e| format!("failed to build tray menu: {e}"))?;
|
||||
|
||||
let _tray = tauri::tray::TrayIconBuilder::with_id("main-tray")
|
||||
.icon(TRAY_ICON)
|
||||
.icon_as_template(false)
|
||||
.tooltip("maarcadetweet")
|
||||
.menu(&tray_menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"tray_show" => {
|
||||
let _ = tauri::Emitter::emit(app, "app://show", ());
|
||||
}
|
||||
"tray_compose" => {
|
||||
let _ = tauri::Emitter::emit(app, "app://compose", ());
|
||||
}
|
||||
"tray_quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Left click on the tray icon brings the app forward;
|
||||
// right click is handled by the menu (see
|
||||
// `show_menu_on_left_click(false)`).
|
||||
if let tauri::tray::TrayIconEvent::Click {
|
||||
button: tauri::tray::MouseButton::Left,
|
||||
button_state: tauri::tray::MouseButtonState::Down,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let _ = tauri::Emitter::emit(tray.app_handle(), "app://show", ());
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
.map_err(|e| format!("failed to build tray icon: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
app_version,
|
||||
pds_describe,
|
||||
auth_register,
|
||||
auth_login,
|
||||
auth_refresh,
|
||||
auth_logout,
|
||||
current_session,
|
||||
post_create,
|
||||
resolve_handle,
|
||||
timeline_home,
|
||||
profile_get,
|
||||
search,
|
||||
post_get,
|
||||
like_post,
|
||||
unlike_post,
|
||||
repost_post,
|
||||
unrepost_post,
|
||||
status_pds,
|
||||
fetch_blob,
|
||||
show_notification,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running maarcadetweet");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
maarcadetweet_app_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PdsHttpClient {
|
||||
pub base_url: String,
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl PdsHttpClient {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAccountReq {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AccountSession {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateRecordReq {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub record: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateRecordResp {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
/// `app.bsky.feed.like.create` request body — the flat BSky shape.
|
||||
/// The Tauri command always uses this shape (rather than the
|
||||
/// generic `createRecord` body) so the PDS can hardcode the
|
||||
/// collection.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateLikeBody {
|
||||
pub repo: String,
|
||||
pub subject: SubjectRef,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SubjectRef {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
/// `com.atproto.repo.deleteRecord` body. Reused for unlike and
|
||||
/// unrepost — the caller just sets `collection` to
|
||||
/// `app.bsky.feed.like` or `app.bsky.feed.repost`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeleteRecordBody {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
}
|
||||
|
||||
/// Response from `feed.like.create` and (structurally) any
|
||||
/// `createRecord` variant. The PDS returns `{uri, cid, commit}`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RepoWriteResp {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
#[serde(default)]
|
||||
pub commit: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Response from `com.atproto.repo.deleteRecord`. Spec returns
|
||||
/// `{ commit: { cid, rev } }`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeleteRecordResp {
|
||||
pub commit: serde_json::Value,
|
||||
}
|
||||
|
||||
impl PdsHttpClient {
|
||||
pub async fn describe_server(&self) -> Result<serde_json::Value> {
|
||||
let r = self
|
||||
.client
|
||||
.get(format!("{}/xrpc/com.atproto.server.describeServer", self.base_url))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
&self,
|
||||
handle: &str,
|
||||
password: &str,
|
||||
) -> Result<AccountSession> {
|
||||
let body = CreateAccountReq {
|
||||
handle: handle.to_string(),
|
||||
email: None,
|
||||
password: password.to_string(),
|
||||
};
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", self.base_url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("createAccount failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
&self,
|
||||
identifier: &str,
|
||||
password: &str,
|
||||
) -> Result<AccountSession> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createSession", self.base_url))
|
||||
.json(&serde_json::json!({"identifier": identifier, "password": password}))
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("createSession failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<AccountSession> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.server.refreshSession", self.base_url))
|
||||
.json(&serde_json::json!({"refresh_jwt": refresh_jwt}))
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("refreshSession failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_record_with(
|
||||
&self,
|
||||
repo: &str,
|
||||
collection: &str,
|
||||
record: serde_json::Value,
|
||||
_validate: bool,
|
||||
jwt: &str,
|
||||
) -> Result<CreateRecordResp> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url))
|
||||
.bearer_auth(jwt)
|
||||
.json(&CreateRecordReq {
|
||||
repo: repo.to_string(),
|
||||
collection: collection.to_string(),
|
||||
record,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("createRecord failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_record(
|
||||
&self,
|
||||
repo: &str,
|
||||
collection: &str,
|
||||
record: serde_json::Value,
|
||||
jwt: &str,
|
||||
) -> Result<CreateRecordResp> {
|
||||
self.create_record_with(repo, collection, record, true, jwt).await
|
||||
}
|
||||
|
||||
pub async fn resolve_handle(&self, handle: &str) -> Result<Option<String>> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.identity.resolveHandle", self.base_url))
|
||||
.json(&serde_json::json!({"handle": handle}))
|
||||
.send()
|
||||
.await?;
|
||||
if r.status().as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !r.status().is_success() {
|
||||
anyhow::bail!("resolveHandle failed: {}", r.status());
|
||||
}
|
||||
let v: serde_json::Value = r.json().await?;
|
||||
Ok(v.get("did").and_then(|x| x.as_str()).map(String::from))
|
||||
}
|
||||
|
||||
/// `POST /xrpc/com.atproto.feed.like.create`
|
||||
///
|
||||
/// `repo` is the caller's DID; the JWT authenticates the call
|
||||
/// and must match. `subject` is the post being liked
|
||||
/// (`strongRef` = `{uri, cid}`).
|
||||
pub async fn create_like(
|
||||
&self,
|
||||
repo: &str,
|
||||
subject_uri: &str,
|
||||
subject_cid: &str,
|
||||
jwt: &str,
|
||||
) -> Result<RepoWriteResp> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.feed.like.create",
|
||||
self.base_url
|
||||
))
|
||||
.bearer_auth(jwt)
|
||||
.json(&CreateLikeBody {
|
||||
repo: repo.to_string(),
|
||||
subject: SubjectRef {
|
||||
uri: subject_uri.to_string(),
|
||||
cid: subject_cid.to_string(),
|
||||
},
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("feed.like.create failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
/// `POST /xrpc/com.atproto.repo.deleteRecord`
|
||||
///
|
||||
/// Generic record deletion — `collection` is the NSID
|
||||
/// (`app.bsky.feed.like`, `app.bsky.feed.repost`, etc.) and
|
||||
/// `rkey` is the trailing component of the record's URI.
|
||||
pub async fn delete_record(
|
||||
&self,
|
||||
repo: &str,
|
||||
collection: &str,
|
||||
rkey: &str,
|
||||
jwt: &str,
|
||||
) -> Result<DeleteRecordResp> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.deleteRecord",
|
||||
self.base_url
|
||||
))
|
||||
.bearer_auth(jwt)
|
||||
.json(&DeleteRecordBody {
|
||||
repo: repo.to_string(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("repo.deleteRecord failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
/// `GET /xrpc/com.atproto.sync.getBlob?did=<did>&cid=<cid>`
|
||||
///
|
||||
/// Streams raw blob bytes from the user's PDS. `jwt` is currently
|
||||
/// unused — `com.atproto.sync.getBlob` is unauthenticated in this
|
||||
/// implementation, matching the other sync reads — but we keep
|
||||
/// the parameter in the signature so future auth-gated calls don't
|
||||
/// force a wire-format change.
|
||||
pub async fn get_blob(
|
||||
&self,
|
||||
did: &str,
|
||||
cid: &str,
|
||||
jwt: Option<&str>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut req = self.client.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getBlob",
|
||||
self.base_url
|
||||
));
|
||||
if let Some(t) = jwt {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
let resp = req
|
||||
.query(&[("did", did), ("cid", cid)])
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
// Try to capture the XRPC error body for easier debugging.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("sync.getBlob failed: {} {}", status, body);
|
||||
}
|
||||
let bytes = resp.bytes().await?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub struct AppState {
|
||||
pub pds: crate::pds_client::PdsHttpClient,
|
||||
pub appview: crate::appview_client::AppViewClient,
|
||||
pub store: crate::store::SessionStore,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::AccountSession;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
inner: Arc<Mutex<Option<AccountSession>>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
pub fn save(&self, sess: &AccountSession) {
|
||||
*self.inner.lock() = Some(sess.clone());
|
||||
}
|
||||
pub fn load(&self) -> Option<AccountSession> {
|
||||
self.inner.lock().clone()
|
||||
}
|
||||
pub fn clear(&self) {
|
||||
*self.inner.lock() = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "maarcadetweet",
|
||||
"version": "0.1.0",
|
||||
"identifier": "de.eifelcloud.maarcadetweet",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1430",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "maarcadetweet",
|
||||
"width": 1200,
|
||||
"height": 760,
|
||||
"minWidth": 880,
|
||||
"minHeight": 540,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#0D0D0D"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": true,
|
||||
"dialog": true,
|
||||
"endpoints": [
|
||||
"https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}"
|
||||
],
|
||||
"pubkey": ""
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import {
|
||||
session,
|
||||
pdsStatus,
|
||||
fetchTimeline,
|
||||
fetchProfile,
|
||||
fetchSearch,
|
||||
fetchPost,
|
||||
type Session,
|
||||
type Post,
|
||||
type ProfileResponse,
|
||||
} from "./lib/api/client";
|
||||
import NavRail from "./lib/components/NavRail.svelte";
|
||||
import StatusBar from "./lib/components/StatusBar.svelte";
|
||||
import PostCard from "./lib/components/PostCard.svelte";
|
||||
import ComposeBox from "./lib/components/ComposeBox.svelte";
|
||||
import LoginScreen from "./lib/components/LoginScreen.svelte";
|
||||
import Terminal from "./lib/components/Terminal.svelte";
|
||||
import Skeleton from "./lib/components/Skeleton.svelte";
|
||||
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
|
||||
let view: View = $state("home");
|
||||
let currentUser: Session | null = $state(null);
|
||||
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
|
||||
|
||||
// Home timeline state.
|
||||
let userPosts: Post[] = $state([]);
|
||||
let timelineCursor: string | null = $state(null);
|
||||
let timelineLoading: boolean = $state(false);
|
||||
let timelineError: string | null = $state(null);
|
||||
let seenUris: Set<string> = new Set();
|
||||
let _statusTimer: number | undefined;
|
||||
let _timelinePollTimer: number | undefined;
|
||||
|
||||
// Profile state.
|
||||
let profile: ProfileResponse | null = $state(null);
|
||||
let profileLoading: boolean = $state(false);
|
||||
let profileError: string | null = $state(null);
|
||||
|
||||
// Search state.
|
||||
let searchQuery: string = $state("");
|
||||
let searchResults: Post[] = $state([]);
|
||||
let searchLoading: boolean = $state(false);
|
||||
let searchError: string | null = $state(null);
|
||||
let _searchDebounce: number | undefined;
|
||||
|
||||
// Thread state — when set, shows the thread root + parent + the focused post.
|
||||
let threadRoot: Post | null = $state(null);
|
||||
let threadParent: Post | null = $state(null);
|
||||
let threadLoading: boolean = $state(false);
|
||||
let threadError: string | null = $state(null);
|
||||
|
||||
// Toasts surfaced by child components via the `maarcadetweet:toast`
|
||||
// window event. We keep the last few so a slow render doesn't
|
||||
// wipe the message before the user reads it.
|
||||
let toasts: { id: number; kind: "error" | "info"; text: string }[] = $state([]);
|
||||
let _toastCounter = 0;
|
||||
function pushToast(kind: "error" | "info", text: string) {
|
||||
const id = ++_toastCounter;
|
||||
toasts = [...toasts, { id, kind, text }];
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function openThread(root_uri: string) {
|
||||
threadLoading = true;
|
||||
threadError = null;
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
try {
|
||||
const r = await fetchPost(root_uri);
|
||||
threadRoot = r.post;
|
||||
threadParent = r.thread.parent;
|
||||
} catch (e) {
|
||||
threadError = String(e);
|
||||
} finally {
|
||||
threadLoading = false;
|
||||
}
|
||||
}
|
||||
function closeThread() {
|
||||
threadRoot = null;
|
||||
threadParent = null;
|
||||
threadError = null;
|
||||
}
|
||||
|
||||
// Tray / notification events dispatched from `main.ts` as DOM
|
||||
// CustomEvents (so we don't depend on `@tauri-apps/api/event` here
|
||||
// — `main.ts` is the only place that wires to the Tauri event bus).
|
||||
function onNavigateToView(e: Event) {
|
||||
const detail = (e as CustomEvent<{ view: View }>).detail;
|
||||
if (detail?.view) view = detail.view;
|
||||
}
|
||||
function onNotification(e: Event) {
|
||||
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
|
||||
if (detail) {
|
||||
// Surface in the existing toast stack — works for both the
|
||||
// OS-notification click and the in-app events.
|
||||
pushToast("info", `notif: ${detail.title} — ${detail.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// We need to hoist these into the onMount closure (which is below)
|
||||
// so the addEventListener / removeEventListener pair stays paired.
|
||||
// (`_toastHandler` is the function below; the other two are
|
||||
// reassigned inside the async onMount body.)
|
||||
let _navigateHandler: ((e: Event) => void) | null = null;
|
||||
let _notificationHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
// Track cleanup refs WITHOUT awaiting before registering them.
|
||||
// Svelte 5's `onDestroy` throws when called after the parent component
|
||||
// context has been torn down (i.e. after an `await` in `onMount`).
|
||||
// Register everything synchronously inside `onMount`, then do async
|
||||
// work afterward.
|
||||
let _sessionUnsub: (() => void) | null = null;
|
||||
let _destroyRegistered = false;
|
||||
function registerCleanup(teardown: () => void) {
|
||||
if (_destroyRegistered) return;
|
||||
onDestroy(teardown);
|
||||
_destroyRegistered = true;
|
||||
}
|
||||
|
||||
function _toastHandler(ev: Event) {
|
||||
const e = ev as CustomEvent<{ kind: "error" | "info"; text: string }>;
|
||||
if (!e.detail) return;
|
||||
pushToast(e.detail.kind, e.detail.text);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
registerCleanup(() => {
|
||||
if (_sessionUnsub) _sessionUnsub();
|
||||
if (_statusTimer) clearInterval(_statusTimer);
|
||||
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
|
||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("maarcadetweet:toast", _toastHandler);
|
||||
window.removeEventListener("maarcadetweet:navigate", _navigateHandler!);
|
||||
window.removeEventListener("maarcadetweet:notification", _notificationHandler!);
|
||||
}
|
||||
});
|
||||
|
||||
// Now safe to await.
|
||||
(async () => {
|
||||
await session.load();
|
||||
_sessionUnsub = session.subscribe((s) => {
|
||||
currentUser = s;
|
||||
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s };
|
||||
});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
_navigateHandler = onNavigateToView;
|
||||
_notificationHandler = onNotification;
|
||||
window.addEventListener("maarcadetweet:toast", _toastHandler);
|
||||
window.addEventListener("maarcadetweet:navigate", _navigateHandler);
|
||||
window.addEventListener("maarcadetweet:notification", _notificationHandler);
|
||||
}
|
||||
|
||||
_statusTimer = window.setInterval(async () => {
|
||||
try {
|
||||
status = await pdsStatus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 5000);
|
||||
})();
|
||||
});
|
||||
|
||||
// Re-fetch the home timeline whenever we navigate to "home" or
|
||||
// when the logged-in user changes. We also poll every 5s while
|
||||
// the home view is active so new posts trickle in.
|
||||
$effect(() => {
|
||||
if (view === "home" && currentUser) {
|
||||
void refreshTimeline(true);
|
||||
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
|
||||
_timelinePollTimer = window.setInterval(() => {
|
||||
void refreshTimeline(false); // poll = prepend new posts, don't wipe
|
||||
}, 5000);
|
||||
} else if (_timelinePollTimer) {
|
||||
clearInterval(_timelinePollTimer);
|
||||
_timelinePollTimer = undefined;
|
||||
}
|
||||
|
||||
if (view === "profile" && currentUser) {
|
||||
void refreshProfile(currentUser.handle);
|
||||
}
|
||||
|
||||
if (view === "search" && currentUser && searchQuery.trim().length > 0) {
|
||||
scheduleSearch();
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshTimeline(reset: boolean) {
|
||||
if (!currentUser) return;
|
||||
timelineLoading = true;
|
||||
timelineError = null;
|
||||
try {
|
||||
const r = await fetchTimeline(currentUser.did, null, 30);
|
||||
if (reset) {
|
||||
// Full replace on explicit refresh.
|
||||
seenUris = new Set(r.posts.map((p) => p.uri));
|
||||
userPosts = r.posts;
|
||||
timelineCursor = r.cursor;
|
||||
} else {
|
||||
// Poll: prepend posts we haven't seen yet.
|
||||
const fresh: Post[] = [];
|
||||
for (const p of r.posts) {
|
||||
if (!seenUris.has(p.uri)) {
|
||||
fresh.push(p);
|
||||
seenUris.add(p.uri);
|
||||
}
|
||||
}
|
||||
if (fresh.length > 0) userPosts = [...fresh, ...userPosts];
|
||||
}
|
||||
} catch (e) {
|
||||
timelineError = String(e);
|
||||
// Keep whatever we had on a transient failure.
|
||||
} finally {
|
||||
timelineLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreTimeline() {
|
||||
if (!currentUser || !timelineCursor || timelineLoading) return;
|
||||
timelineLoading = true;
|
||||
try {
|
||||
const r = await fetchTimeline(currentUser.did, timelineCursor, 30);
|
||||
for (const p of r.posts) {
|
||||
if (!seenUris.has(p.uri)) {
|
||||
seenUris.add(p.uri);
|
||||
userPosts = [...userPosts, p];
|
||||
}
|
||||
}
|
||||
timelineCursor = r.cursor;
|
||||
} catch (e) {
|
||||
timelineError = String(e);
|
||||
} finally {
|
||||
timelineLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProfile(handle: string) {
|
||||
profileLoading = true;
|
||||
profileError = null;
|
||||
try {
|
||||
profile = await fetchProfile(handle);
|
||||
} catch (e) {
|
||||
profileError = String(e);
|
||||
profile = null;
|
||||
} finally {
|
||||
profileLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch() {
|
||||
if (_searchDebounce) clearTimeout(_searchDebounce);
|
||||
_searchDebounce = window.setTimeout(() => {
|
||||
void runSearch();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
// Empty input -> immediately clear results (no debounce).
|
||||
if (searchQuery.trim().length === 0) {
|
||||
if (_searchDebounce) {
|
||||
clearTimeout(_searchDebounce);
|
||||
_searchDebounce = undefined;
|
||||
}
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
scheduleSearch();
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const needle = searchQuery.trim();
|
||||
if (!needle) {
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
searchLoading = true;
|
||||
searchError = null;
|
||||
try {
|
||||
const r = await fetchSearch(needle, 30);
|
||||
searchResults = r.posts;
|
||||
} catch (e) {
|
||||
searchError = String(e);
|
||||
searchResults = [];
|
||||
} finally {
|
||||
searchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePosted() {
|
||||
// After the user posts, reset to page 1 so they see their own post.
|
||||
await refreshTimeline(true);
|
||||
}
|
||||
|
||||
// Derive a display handle. The session already gives us the user's
|
||||
// real handle (e.g. "alice.bsky.social"). When the AppView decorates
|
||||
// posts that have empty handles it falls back to a synthetic
|
||||
// "@did:plc:abcd…" form, so the fallback here matches that.
|
||||
function displayHandle(h: string | null | undefined): string {
|
||||
if (!h) return "@unknown";
|
||||
if (h.startsWith("@")) return h;
|
||||
return `@${h}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !currentUser}
|
||||
<div class="login-wrap">
|
||||
<LoginScreen
|
||||
onLogin={(s) => {
|
||||
currentUser = s;
|
||||
view = "home";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="shell">
|
||||
<NavRail bind:current={view} />
|
||||
<div class="main">
|
||||
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
|
||||
{#if view === "home"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// home —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">→ {userPosts.length} posts · polling every 5s</span>
|
||||
</div>
|
||||
{#if timelineError}
|
||||
<div class="toast toast--err">err: {timelineError}</div>
|
||||
{/if}
|
||||
{#if timelineLoading && userPosts.length === 0}
|
||||
<Skeleton rows={3} />
|
||||
{:else if userPosts.length === 0}
|
||||
<div class="empty">// timeline is empty. compose your first post →</div>
|
||||
{:else}
|
||||
{#if threadRoot}
|
||||
<div class="thread-modal">
|
||||
<header class="thread-modal__head">
|
||||
<span class="crumb">// thread</span>
|
||||
<button class="btn--ghost" onclick={closeThread}>close</button>
|
||||
</header>
|
||||
{#if threadLoading}
|
||||
<Skeleton rows={2} />
|
||||
{:else if threadError}
|
||||
<div class="toast toast--err">err: {threadError}</div>
|
||||
{:else if threadRoot}
|
||||
{#if threadParent && threadParent.uri !== threadRoot.uri}
|
||||
<div class="thread-parent"><PostCard post={threadParent} /></div>
|
||||
{/if}
|
||||
<PostCard post={threadRoot} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#each userPosts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
{/each}
|
||||
{#if timelineCursor}
|
||||
<div class="loadmore">
|
||||
<button class="btn btn--ghost" onclick={() => void loadMoreTimeline()} disabled={timelineLoading}>
|
||||
{timelineLoading ? "loading…" : "load more"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if view === "compose"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// compose —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
<span class="meta">⌘↵ to post</span>
|
||||
</div>
|
||||
<ComposeBox onPosted={handlePosted} />
|
||||
{:else if view === "profile"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// profile —</span>
|
||||
<span class="as">@{currentUser.handle}</span>
|
||||
</div>
|
||||
{#if profileLoading && !profile}
|
||||
<Skeleton rows={4} />
|
||||
{:else if profileError}
|
||||
<div class="toast toast--err">err: {profileError}</div>
|
||||
{:else if profile}
|
||||
<section class="profile">
|
||||
<header class="profile__head">
|
||||
<span class="profile__handle">{displayHandle(profile.handle)}</span>
|
||||
<span class="profile__did" title={profile.did}>{profile.did}</span>
|
||||
</header>
|
||||
<dl class="counts">
|
||||
<div>
|
||||
<dt>followers</dt>
|
||||
<dd>{profile.followers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>following</dt>
|
||||
<dd>{profile.following}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>posts</dt>
|
||||
<dd>{profile.posts.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if profile.posts.length === 0}
|
||||
<div class="empty">// no posts yet</div>
|
||||
{:else}
|
||||
{#each profile.posts as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{:else if view === "search"}
|
||||
<div class="head">
|
||||
<span class="prompt">$</span>
|
||||
<span class="title">// search</span>
|
||||
</div>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
placeholder="grep posts…"
|
||||
/>
|
||||
{#if searchError}
|
||||
<div class="toast toast--err">err: {searchError}</div>
|
||||
{/if}
|
||||
{#if searchLoading}
|
||||
<Skeleton rows={2} />
|
||||
{:else if searchQuery.trim().length === 0}
|
||||
<div class="empty">// type to search…</div>
|
||||
{:else if searchResults.length === 0}
|
||||
<div class="empty">// no posts match "{searchQuery}"</div>
|
||||
{:else}
|
||||
<div class="meta meta--results">{searchResults.length} result{searchResults.length === 1 ? "" : "s"} for "{searchQuery}"</div>
|
||||
{#each searchResults as p (p.uri)}
|
||||
<PostCard post={p} on_thread_click={openThread} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</Terminal>
|
||||
</div>
|
||||
<StatusBar did={status.did ?? ""} authenticated={status.authenticated} />
|
||||
</div>
|
||||
{#if toasts.length > 0}
|
||||
<div class="toasts" role="status" aria-live="polite">
|
||||
{#each toasts as t (t.id)}
|
||||
<button
|
||||
class="toast-pill"
|
||||
class:toast-pill--err={t.kind === "error"}
|
||||
type="button"
|
||||
onclick={() => (toasts = toasts.filter((x) => x.id !== t.id))}
|
||||
title="dismiss"
|
||||
>
|
||||
{t.kind === "error" ? "err" : "info"}: {t.text}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.login-wrap {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 24px;
|
||||
grid-template-columns: 88px 1fr;
|
||||
grid-template-areas:
|
||||
"rail main"
|
||||
"rail status";
|
||||
height: 100%;
|
||||
}
|
||||
.shell > :global(nav.rail) { grid-area: rail; }
|
||||
.shell > :global(.statusbar) { grid-area: status; }
|
||||
.main {
|
||||
grid-area: main;
|
||||
overflow: auto;
|
||||
padding: var(--s-3);
|
||||
}
|
||||
.head {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-3);
|
||||
align-items: center;
|
||||
padding: 0 var(--s-2) var(--s-3);
|
||||
border-bottom: 1px dashed var(--line);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
.prompt { color: var(--orange); }
|
||||
.title { color: var(--orange); font-weight: 700; }
|
||||
.as { color: var(--text); }
|
||||
.meta { color: var(--text-dim); margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
.meta--results { padding: var(--s-2) var(--s-5); margin: 0; }
|
||||
|
||||
.toast {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
margin: 0 var(--s-5) var(--s-3);
|
||||
border-left: 3px solid var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
color: var(--red);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.toast--err { border-left-color: var(--red); }
|
||||
|
||||
.empty {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
padding: var(--s-4) var(--s-5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: block;
|
||||
width: calc(100% - 2 * var(--s-5));
|
||||
margin: 0 var(--s-5) var(--s-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
}
|
||||
.search:focus { border-color: var(--orange); }
|
||||
.search::placeholder { color: var(--text-dim); }
|
||||
|
||||
.loadmore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--s-4) var(--s-5);
|
||||
}
|
||||
.btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.profile {
|
||||
padding: 0 var(--s-3);
|
||||
}
|
||||
.profile__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-3) 0 var(--s-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
.profile__handle {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
color: var(--orange);
|
||||
}
|
||||
.profile__did {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
word-break: break-all;
|
||||
}
|
||||
.counts {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
margin: 0 0 var(--s-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.counts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.counts dt { color: var(--text-dim); letter-spacing: 0.04em; }
|
||||
.counts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-200);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: var(--s-4);
|
||||
bottom: calc(24px + var(--s-3));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
z-index: 100;
|
||||
max-width: 360px;
|
||||
}
|
||||
.toast-pill {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line-2);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.toast-pill:hover { border-color: var(--orange); }
|
||||
.toast-pill--err {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
@import "./lib/styles/tokens.css";
|
||||
|
||||
@font-face {
|
||||
font-family: "IBMPlexMono";
|
||||
src: url("/fonts/IBMPlexMono-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBMPlexMono";
|
||||
src: url("/fonts/IBMPlexMono-Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBMPlexMono";
|
||||
src: url("/fonts/IBMPlexMono-SemiBold.ttf") format("truetype");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBMPlexMono";
|
||||
src: url("/fonts/IBMPlexMono-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "NotoSans";
|
||||
src: url("/fonts/NotoSans-VF.ttf") format("truetype-variations"),
|
||||
url("/fonts/NotoSans-VF.ttf") format("truetype");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-100);
|
||||
line-height: var(--lh-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image:
|
||||
linear-gradient(var(--orange-3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--orange-3) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
background-position: center top;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--lh-tight);
|
||||
margin: 0;
|
||||
}
|
||||
p { margin: 0; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
|
||||
::selection { background: var(--orange); color: var(--bg); }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--orange);
|
||||
outline-offset: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--line); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#0D0D0D"/>
|
||||
<circle cx="16" cy="16" r="11" fill="rgba(255,102,0,0.08)" stroke="rgba(255,102,0,0.5)" stroke-width="1.2"/>
|
||||
<g font-family="ui-monospace, monospace" font-weight="700" font-size="11" fill="#FF6600" text-anchor="middle">
|
||||
<text x="16" y="21">>_</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="12" fill="#0D0D0D"/>
|
||||
<circle cx="32" cy="32" r="22" fill="rgba(255,102,0,0.08)" stroke="rgba(255,102,0,0.5)" stroke-width="1.5"/>
|
||||
<g font-family="ui-monospace, monospace" font-weight="700" font-size="22" fill="#FF6600" text-anchor="middle">
|
||||
<text x="32" y="42">>_</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
@@ -0,0 +1,348 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export type Session = {
|
||||
did: string;
|
||||
handle: string;
|
||||
access_jwt: string;
|
||||
refresh_jwt: string;
|
||||
};
|
||||
|
||||
function createSessionStore() {
|
||||
const { subscribe, set } = writable<Session | null>(null);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
async load() {
|
||||
try {
|
||||
const s = await invoke<Session | null>("current_session");
|
||||
set(s);
|
||||
} catch (e) {
|
||||
console.error("current_session failed", e);
|
||||
}
|
||||
},
|
||||
async login(handle: string, password: string) {
|
||||
const s = await invoke<Session>("auth_login", { identifier: handle, password });
|
||||
set(s);
|
||||
return s;
|
||||
},
|
||||
async register(handle: string, password: string) {
|
||||
const s = await invoke<Session>("auth_register", { handle, password });
|
||||
set(s);
|
||||
return s;
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await invoke("auth_logout");
|
||||
} catch (e) {
|
||||
console.error("logout failed", e);
|
||||
}
|
||||
set(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const session = createSessionStore();
|
||||
|
||||
/// AT-Protocol embed variants. We keep the on-the-wire JSON verbatim
|
||||
/// (instead of narrowing to one specific shape per variant) so that
|
||||
/// adding a new embed type upstream doesn't require a frontend change.
|
||||
/// The UI sniffs `embed.$type` to decide which sub-component to render.
|
||||
export type Embed = {
|
||||
$type: string;
|
||||
images?: EmbedImage[];
|
||||
external?: EmbedExternal;
|
||||
record?: EmbedRecord;
|
||||
media?: EmbedRecordWithMedia;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
export type EmbedImage = {
|
||||
alt?: string;
|
||||
image?: unknown;
|
||||
aspectRatio?: { width: number; height: number };
|
||||
};
|
||||
|
||||
export type EmbedExternal = {
|
||||
uri: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
thumb?: string;
|
||||
};
|
||||
|
||||
export type EmbedRecord = {
|
||||
uri: string;
|
||||
cid?: string;
|
||||
author?: { did: string; handle?: string };
|
||||
value?: { text?: string; createdAt?: string };
|
||||
};
|
||||
|
||||
export type EmbedRecordWithMedia = Embed & {
|
||||
record: EmbedRecord;
|
||||
media: { images?: EmbedImage[]; external?: EmbedExternal };
|
||||
};
|
||||
|
||||
/// Post shape returned by the AppView REST API. The Tauri command
|
||||
/// (also named `Post` on the Rust side) re-exports this as
|
||||
/// `appview_client::PostDto` and the frontend receives it as-is.
|
||||
export type Post = {
|
||||
uri: string;
|
||||
did: string;
|
||||
handle: string;
|
||||
rkey: string;
|
||||
collection: string;
|
||||
text: string;
|
||||
cid: string;
|
||||
parent_uri?: string | null;
|
||||
root_uri?: string | null;
|
||||
embed?: Embed | null;
|
||||
langs: string[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TimelineResponse = {
|
||||
posts: Post[];
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export type ProfileResponse = {
|
||||
did: string;
|
||||
handle: string;
|
||||
posts: Post[];
|
||||
followers: number;
|
||||
following: number;
|
||||
};
|
||||
|
||||
export type SearchResponse = {
|
||||
posts: Post[];
|
||||
q: string;
|
||||
};
|
||||
|
||||
/// `GET /api/post/{uri}` response. Used by the UI when the user
|
||||
/// expands a reply to fetch the parent + root in one round trip.
|
||||
///
|
||||
/// `like_count` and `repost_count` are present when the post was
|
||||
/// found; they're `undefined` (or absent) for the "not in index"
|
||||
/// sentinel response (where `post` is null). AppView has no auth
|
||||
/// yet, so `viewer_liked` / `viewer_reposted` aren't returned.
|
||||
export type ThreadResponse = {
|
||||
post: Post | null;
|
||||
thread: {
|
||||
parent: Post | null;
|
||||
root: Post | null;
|
||||
};
|
||||
like_count?: number;
|
||||
repost_count?: number;
|
||||
};
|
||||
|
||||
export async function createPost(text: string): Promise<Post> {
|
||||
// The Rust post_create command returns a different shape (uri+cid
|
||||
// only), but we keep the call simple: it gives us the cid we need
|
||||
// to show the "ok" toast.
|
||||
return await invoke<any>("post_create", { text });
|
||||
}
|
||||
|
||||
export async function describeServer(): Promise<any> {
|
||||
return await invoke("pds_describe");
|
||||
}
|
||||
|
||||
export async function pdsStatus(): Promise<any> {
|
||||
return await invoke("status_pds");
|
||||
}
|
||||
|
||||
export async function fetchTimeline(
|
||||
did: string,
|
||||
cursor: string | null = null,
|
||||
limit: number = 30,
|
||||
): Promise<TimelineResponse> {
|
||||
return await invoke<TimelineResponse>("timeline_home", {
|
||||
did,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchProfile(handle: string): Promise<ProfileResponse> {
|
||||
return await invoke<ProfileResponse>("profile_get", { handle });
|
||||
}
|
||||
|
||||
export async function fetchSearch(
|
||||
q: string,
|
||||
limit: number = 30,
|
||||
): Promise<SearchResponse> {
|
||||
return await invoke<SearchResponse>("search", { q, limit });
|
||||
}
|
||||
|
||||
export async function fetchPost(uri: string): Promise<ThreadResponse> {
|
||||
return await invoke<ThreadResponse>("post_get", { uri });
|
||||
}
|
||||
|
||||
/// `app.bsky.feed.like.create` — Tauri command. Builds the
|
||||
/// flat-shape like body on the Rust side, signs a commit, pushes
|
||||
/// to the AppView. Returns the new like's `uri` and `cid`.
|
||||
export type RepoWriteResult = { uri: string; cid: string };
|
||||
|
||||
/// `com.atproto.repo.deleteRecord` — Tauri command. Used for
|
||||
/// both unlike and unrepost. The Rust side splits the URI into
|
||||
/// `rkey` and sends it.
|
||||
export type DeleteRecordResult = { commit: { cid: string; rev: string } };
|
||||
|
||||
export async function likePost(
|
||||
subjectUri: string,
|
||||
subjectCid: string,
|
||||
): Promise<RepoWriteResult> {
|
||||
return await invoke<RepoWriteResult>("like_post", {
|
||||
subjectUri,
|
||||
subjectCid,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unlikePost(likeUri: string): Promise<DeleteRecordResult> {
|
||||
return await invoke<DeleteRecordResult>("unlike_post", { likeUri });
|
||||
}
|
||||
|
||||
export async function repostPost(
|
||||
subjectUri: string,
|
||||
subjectCid: string,
|
||||
): Promise<RepoWriteResult> {
|
||||
return await invoke<RepoWriteResult>("repost_post", {
|
||||
subjectUri,
|
||||
subjectCid,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unrepostPost(
|
||||
repostUri: string,
|
||||
): Promise<DeleteRecordResult> {
|
||||
return await invoke<DeleteRecordResult>("unrepost_post", { repostUri });
|
||||
}
|
||||
|
||||
/// Fire-and-forget user-visible error toast. Implemented as a
|
||||
/// `window` `CustomEvent` so any component can show errors without
|
||||
/// pulling in a global store. `App.svelte` listens for the event
|
||||
/// and renders the toast UI.
|
||||
export function showError(text: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("maarcadetweet:toast", { detail: { kind: "error", text } }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a native OS notification. Thin wrapper around the
|
||||
/// `show_notification` Tauri command. The Rust side also emits an
|
||||
/// `app://notification` event with the same payload, so the click
|
||||
/// listener (registered via `listenNotification`) can route to a URL.
|
||||
///
|
||||
/// Pass `url` to make the notification clickable: when the user
|
||||
/// clicks, the Rust command focuses the main window and the JS
|
||||
/// listener navigates.
|
||||
export async function showNotification(
|
||||
title: string,
|
||||
body: string,
|
||||
url?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await invoke("show_notification", { title, body, url: url ?? null });
|
||||
} catch (e) {
|
||||
console.error("show_notification failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to system-tray menu events. The Tauri tray menu emits
|
||||
/// `app://show` ("Show maarcadetweet") and `app://compose` ("Compose");
|
||||
/// the Rust side already turns left-clicks into `app://show` and the
|
||||
/// "Quit" menu item into a clean `app.exit(0)`.
|
||||
///
|
||||
/// The handler receives the event payload (empty object for these
|
||||
/// cases). Returns an unsubscribe function.
|
||||
export async function listenTrayEvents(
|
||||
handler: (event: "show" | "compose") => void,
|
||||
): Promise<() => void> {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
const unlisteners: Array<() => void> = [];
|
||||
const u1 = await listen("app://show", () => handler("show"));
|
||||
const u2 = await listen("app://compose", () => handler("compose"));
|
||||
unlisteners.push(u1, u2);
|
||||
return () => {
|
||||
for (const u of unlisteners) u();
|
||||
};
|
||||
}
|
||||
|
||||
/// Subscribe to `app://notification` events emitted by the Rust
|
||||
/// `show_notification` command. Used to focus the window and
|
||||
/// navigate when the user clicks the notification.
|
||||
export async function listenNotification(
|
||||
handler: (payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
url: string | null;
|
||||
}) => void,
|
||||
): Promise<() => void> {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
const u = await listen<{ title: string; body: string; url: string | null }>(
|
||||
"app://notification",
|
||||
(e) => handler(e.payload),
|
||||
);
|
||||
return u;
|
||||
}
|
||||
|
||||
// -- blob fetch + cache -----------------------------------------------------
|
||||
//
|
||||
// Image embeds reference blobs by CID. The blob bytes themselves
|
||||
// live on the user's PDS — the AppView only has the CID. The Tauri
|
||||
// shell handles the PDS HTTP call (`fetch_blob` command) so the
|
||||
// frontend never has to know the PDS URL.
|
||||
//
|
||||
// We cache the *resolved object URL* (not the raw bytes) keyed
|
||||
// by CID, because:
|
||||
// * the blob bytes are addressed by content hash, so the same
|
||||
// CID always resolves to the same bytes regardless of DID;
|
||||
// * the `<img>` element takes an object URL, not raw bytes, so
|
||||
// handing the URL straight back to the caller saves a
|
||||
// Blob/URL.createObjectURL call per render.
|
||||
const _blobUrlCache = new Map<string, string>();
|
||||
|
||||
/// Fetch the raw blob bytes for `cid` and return an object URL
|
||||
/// suitable for `<img src={...}>`. Caches the URL in-process so
|
||||
/// navigating the timeline doesn't re-download already-seen
|
||||
/// images.
|
||||
export async function fetchBlob(
|
||||
did: string,
|
||||
cid: string,
|
||||
): Promise<string> {
|
||||
const cached = _blobUrlCache.get(cid);
|
||||
if (cached) return cached;
|
||||
const bytes: number[] = await invoke<number[]>("fetch_blob", {
|
||||
did,
|
||||
cid,
|
||||
});
|
||||
const u8 = new Uint8Array(bytes);
|
||||
if (u8.length === 0) {
|
||||
throw new Error(`empty blob for cid ${cid}`);
|
||||
}
|
||||
const blob = new Blob([u8]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
_blobUrlCache.set(cid, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Drop the cached object URL and remove it. Components should
|
||||
/// call this when an `<img>` is unmounted to avoid leaking the
|
||||
/// underlying Blob. For a Tauri WebView with at most a few
|
||||
/// dozen visible images the OS cleans up anyway, but explicit
|
||||
/// revocation makes long sessions friendlier on memory.
|
||||
export function releaseBlob(cid: string): void {
|
||||
const url = _blobUrlCache.get(cid);
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
_blobUrlCache.delete(cid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test/internal: clear the in-memory blob cache.
|
||||
export function clearBlobCache(): void {
|
||||
for (const url of _blobUrlCache.values()) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
_blobUrlCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { createPost, type Post } from "../api/client";
|
||||
|
||||
const MAX = 160;
|
||||
let { onPosted }: { onPosted?: () => void } = $props();
|
||||
let text: string = $state("");
|
||||
let isPosting: boolean = $state(false);
|
||||
let status: { kind: "ok" | "err" | "info"; msg: string } | null = $state(null);
|
||||
|
||||
let remaining = $derived(MAX - text.length);
|
||||
let counterClass = $derived(
|
||||
remaining < 0 ? "counter counter--err" :
|
||||
remaining < 40 ? "counter counter--warn" : "counter"
|
||||
);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
post();
|
||||
}
|
||||
}
|
||||
|
||||
async function post() {
|
||||
if (!text.trim() || remaining < 0 || isPosting) return;
|
||||
isPosting = true;
|
||||
status = { kind: "info", msg: "> posting…" };
|
||||
try {
|
||||
const r: Post = await createPost(text);
|
||||
status = { kind: "ok", msg: `> ok (cid: ${(r as any).cid?.slice?.(0, 8) ?? "?"}…)` };
|
||||
text = "";
|
||||
onPosted?.();
|
||||
} catch (e) {
|
||||
status = { kind: "err", msg: `> ${String(e)}` };
|
||||
} finally {
|
||||
isPosting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="compose">
|
||||
<div class="compose__head">
|
||||
<span class="title">// compose</span>
|
||||
<span class="handle">@you</span>
|
||||
<span class={counterClass}>{remaining}</span>
|
||||
</div>
|
||||
<div class="compose__body">
|
||||
<span class="prompt">$</span>
|
||||
<textarea
|
||||
bind:value={text}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="// what's happening in 160 chars?"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="compose__foot">
|
||||
<span class="hint">⌘↵ to post</span>
|
||||
<div class="actions">
|
||||
<button class="btn btn--ghost" onclick={() => (text = "")} disabled={!text || isPosting}>draft</button>
|
||||
<button class="btn btn--primary" onclick={post} disabled={!text.trim() || remaining < 0 || isPosting}>
|
||||
{isPosting ? "posting…" : "post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if status}
|
||||
<div class="status status--{status.kind}">{status.msg}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.compose {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
margin: var(--s-4) var(--s-5);
|
||||
}
|
||||
.compose__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
background: var(--bg-deep);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.title { color: var(--orange); }
|
||||
.handle { color: var(--text-dim); flex: 1; }
|
||||
.counter { color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.counter--warn { color: var(--orange); }
|
||||
.counter--err { color: var(--red); letter-spacing: 0.05em; }
|
||||
.compose__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
}
|
||||
.prompt {
|
||||
color: var(--orange);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1.7;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
textarea::placeholder { color: var(--text-dim); }
|
||||
.compose__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.hint { font-family: var(--font-mono); font-size: var(--fs-50); color: var(--text-dim); }
|
||||
.actions { display: flex; gap: var(--s-2); }
|
||||
.btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
.btn--ghost { color: var(--text-dim); border-color: var(--line-2); background: transparent; }
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
|
||||
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.status {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.status--ok { color: var(--green); }
|
||||
.status--err { color: var(--red); }
|
||||
.status--info { color: var(--orange); }
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import type { EmbedExternal } from "../api/client";
|
||||
|
||||
let { external }: { external: EmbedExternal } = $props();
|
||||
|
||||
function hostname(uri: string): string {
|
||||
try {
|
||||
return new URL(uri).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="embed-external"
|
||||
href={external.uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<div class="embed-external__body">
|
||||
<div class="embed-external__title">{external.title || external.uri}</div>
|
||||
{#if external.description}
|
||||
<div class="embed-external__desc">{external.description}</div>
|
||||
{/if}
|
||||
<div class="embed-external__host">{hostname(external.uri)}</div>
|
||||
</div>
|
||||
{#if external.thumb}
|
||||
<div class="embed-external__thumb" aria-hidden="true">
|
||||
<span>thumb</span>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.embed-external {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--s-3);
|
||||
margin: var(--s-2) 0 var(--s-3);
|
||||
padding: var(--s-3);
|
||||
border: 1px solid var(--line-2);
|
||||
border-left: 3px solid var(--orange);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-elev);
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: border-color var(--dur) var(--ease);
|
||||
max-width: 520px;
|
||||
}
|
||||
.embed-external:hover { border-color: var(--orange); }
|
||||
.embed-external__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.embed-external__title {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-100);
|
||||
line-height: var(--lh-snug);
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.embed-external__desc {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
line-height: var(--lh-body);
|
||||
word-wrap: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.embed-external__host {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-top: var(--s-1);
|
||||
}
|
||||
.embed-external__thumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
background: var(--bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { fetchBlob, releaseBlob } from "../api/client";
|
||||
|
||||
// The shape of a single image in an `app.bsky.embed.images` record.
|
||||
// We keep it loose because the AppView passes `embed` through as
|
||||
// a JSON blob, not a typed struct.
|
||||
type BlobRef = {
|
||||
$type?: "blob";
|
||||
ref?: { $link?: string };
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type EmbedImageProps = {
|
||||
image: {
|
||||
alt?: string;
|
||||
image?: BlobRef | unknown;
|
||||
aspectRatio?: { width: number; height: number };
|
||||
};
|
||||
/**
|
||||
* DID of the post's author — needed to fetch the blob from the
|
||||
* right PDS. Required for image embeds, optional for record
|
||||
* embeds where we render an icon only.
|
||||
*/
|
||||
did?: string;
|
||||
};
|
||||
|
||||
let { image, did }: EmbedImageProps = $props();
|
||||
|
||||
const blobRef = $derived((image?.image ?? null) as BlobRef | null);
|
||||
const cid = $derived(blobRef?.ref?.$link ?? null);
|
||||
|
||||
// Lifecycle of an image fetch:
|
||||
// * `loading=true` → show a skeleton at the aspect-ratio frame.
|
||||
// * success → render `<img>` with the cached object URL.
|
||||
// * error → render the alt text in the existing
|
||||
// striped placeholder so the user still sees
|
||||
// something (and screen readers get the alt).
|
||||
//
|
||||
// The object URL is cached in `client.ts` so re-renders of the
|
||||
// same CID (e.g. when scrolling the same image back into view)
|
||||
// reuse the same URL instead of allocating a fresh one each time.
|
||||
let objectUrl: string | null = $state(null);
|
||||
let loading: boolean = $state(false);
|
||||
let errored: boolean = $state(false);
|
||||
let errorMsg: string = $state("");
|
||||
|
||||
// Track the cid we last fetched so we know when to release the
|
||||
// URL back to the cache on a cid change.
|
||||
let currentCid: string | null = null;
|
||||
|
||||
async function loadImage(didStr: string, c: string) {
|
||||
loading = true;
|
||||
errored = false;
|
||||
errorMsg = "";
|
||||
try {
|
||||
const url = await fetchBlob(didStr, c);
|
||||
objectUrl = url;
|
||||
currentCid = c;
|
||||
} catch (e) {
|
||||
errored = true;
|
||||
errorMsg = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (cid && did) {
|
||||
loadImage(did, cid);
|
||||
} else {
|
||||
// No cid (or no did) — fall back to alt-text placeholder.
|
||||
errored = !image?.alt;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Release the cached URL when this component goes away.
|
||||
// For a long-running timeline this keeps the in-memory
|
||||
// cache bounded to the visible images. The browser will
|
||||
// free the underlying blob either way when the WebView
|
||||
// navigates; this is just hygiene.
|
||||
if (currentCid) {
|
||||
releaseBlob(currentCid);
|
||||
currentCid = null;
|
||||
}
|
||||
objectUrl = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<figure class="embed-image">
|
||||
<div
|
||||
class="embed-image__frame"
|
||||
class:embed-image__frame--err={errored}
|
||||
class:embed-image__frame--loading={loading}
|
||||
style={image.aspectRatio
|
||||
? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height};`
|
||||
: ""}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="embed-image__skel" aria-busy="true" aria-live="polite">
|
||||
<span class="embed-image__skel-bar"></span>
|
||||
<span class="embed-image__skel-bar embed-image__skel-bar--short"></span>
|
||||
</div>
|
||||
{:else if objectUrl}
|
||||
<img
|
||||
class="embed-image__img"
|
||||
src={objectUrl}
|
||||
alt={image.alt ?? ""}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<span class="embed-image__alt" title={image.alt ?? ""}>
|
||||
{image.alt || (errored ? "image unavailable" : "image")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if image.alt || errored}
|
||||
<figcaption class="embed-image__caption">
|
||||
{errored
|
||||
? `couldn't load image: ${errorMsg}`
|
||||
: `alt: ${image.alt}`}
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.embed-image {
|
||||
margin: var(--s-2) 0 var(--s-3);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-elev);
|
||||
max-width: 480px;
|
||||
}
|
||||
.embed-image__frame {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--bg-elev),
|
||||
var(--bg-elev) 10px,
|
||||
var(--bg) 10px,
|
||||
var(--bg) 20px
|
||||
);
|
||||
}
|
||||
.embed-image__frame--loading {
|
||||
background: var(--bg-elev);
|
||||
animation: img-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.embed-image__frame--err {
|
||||
background: var(--bg);
|
||||
}
|
||||
.embed-image__skel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
padding: var(--s-3);
|
||||
width: 100%;
|
||||
}
|
||||
.embed-image__skel-bar {
|
||||
height: 6px;
|
||||
border-radius: var(--r-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-elev) 0%,
|
||||
var(--line-2) 50%,
|
||||
var(--bg-elev) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: img-shimmer 1.4s ease-in-out infinite;
|
||||
width: 80%;
|
||||
}
|
||||
.embed-image__skel-bar--short { width: 40%; }
|
||||
.embed-image__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.embed-image__alt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
padding: var(--s-3);
|
||||
text-align: center;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.embed-image__caption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
@keyframes img-pulse {
|
||||
0%, 100% { opacity: 0.95; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
@keyframes img-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.embed-image__frame--loading,
|
||||
.embed-image__skel-bar { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { session, describeServer, type Session } from "../api/client";
|
||||
|
||||
let { onLogin }: { onLogin: (s: Session) => void } = $props();
|
||||
|
||||
let mode: "login" | "register" = $state("register");
|
||||
let handle: string = $state("");
|
||||
let password: string = $state("");
|
||||
let busy = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let serverInfo: any = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
serverInfo = await describeServer();
|
||||
} catch (e) {
|
||||
serverInfo = { error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!handle.trim() || !password) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const s = mode === "register"
|
||||
? await session.register(handle, password)
|
||||
: await session.login(handle, password);
|
||||
onLogin(s);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login">
|
||||
<div class="terminal-head">
|
||||
<div class="dots"><i></i><i></i><i></i></div>
|
||||
<div class="t">maarcadetweet — {mode}</div>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<div class="line">
|
||||
<span class="prompt">$</span> maarcadetweet {mode}
|
||||
</div>
|
||||
<div class="line muted">// the timeline that fits in 160 chars.</div>
|
||||
<div class="line"> </div>
|
||||
{#if serverInfo}
|
||||
<div class="line muted">// pds: {serverInfo.did ?? "?"}</div>
|
||||
<div class="line muted">// domains: {(serverInfo.available_user_domains ?? []).join(", ")}</div>
|
||||
{/if}
|
||||
<div class="line"> </div>
|
||||
<div class="form">
|
||||
<label>
|
||||
<span class="key">handle:</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={handle}
|
||||
placeholder="alice.maarcadetweet.local"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="key">password:</span>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="≥ 8 chars"
|
||||
disabled={busy}
|
||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="line err">error: {error}</div>
|
||||
{/if}
|
||||
<div class="line"> </div>
|
||||
<div class="line">
|
||||
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
|
||||
{busy ? "..." : mode === "register" ? "create account" : "login"}
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
|
||||
{mode === "register" ? "have an account? login" : "no account? register"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
width: min(560px, 92vw);
|
||||
}
|
||||
.terminal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dots i {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--line-2);
|
||||
display: block;
|
||||
}
|
||||
.dots i:first-child { background: #4a4a4a; }
|
||||
.t {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-inline: auto;
|
||||
}
|
||||
.terminal-body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.85;
|
||||
padding: var(--s-5);
|
||||
}
|
||||
.line { white-space: pre-wrap; }
|
||||
.muted { color: var(--text-dim); }
|
||||
.prompt { color: var(--orange); }
|
||||
.err { color: var(--red); }
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
margin: var(--s-4) 0;
|
||||
}
|
||||
.form label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.key { color: var(--orange); width: 90px; flex-shrink: 0; }
|
||||
.form input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border-radius: var(--r-sm);
|
||||
outline: none;
|
||||
}
|
||||
.form input:focus { border-color: var(--orange); }
|
||||
.btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
margin-right: var(--s-2);
|
||||
}
|
||||
.btn--primary { background: var(--orange); color: #1a0d00; font-weight: 700; }
|
||||
.btn--primary:hover:not(:disabled) { background: var(--orange-bright); }
|
||||
.btn--ghost { background: transparent; color: var(--text-dim); border-color: var(--line-2); }
|
||||
.btn--ghost:hover:not(:disabled) { color: var(--orange); border-color: var(--orange); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
type View = "home" | "compose" | "profile" | "search";
|
||||
|
||||
let { current = $bindable<View>("home") }: { current: View } = $props();
|
||||
|
||||
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
|
||||
{ id: "home", label: "home", key: "g h", icon: "home" },
|
||||
{ id: "compose", label: "compose", key: "c", icon: "compose" },
|
||||
{ id: "profile", label: "profile", key: "p", icon: "profile" },
|
||||
{ id: "search", label: "search", key: "/", icon: "search" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<nav class="rail" aria-label="navigation">
|
||||
{#each items as item}
|
||||
<button
|
||||
class="rail__btn"
|
||||
class:active={current === item.id}
|
||||
onclick={() => (current = item.id)}
|
||||
title={`${item.label} (${item.key})`}
|
||||
aria-current={current === item.id ? "page" : undefined}
|
||||
>
|
||||
<span class="icon">
|
||||
{#if item.icon === "home"}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
{:else if item.icon === "compose"}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">>_</text>
|
||||
</svg>
|
||||
{:else if item.icon === "profile"}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.rail {
|
||||
width: 88px;
|
||||
background: var(--bg);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-3) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rail__btn {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
padding: var(--s-3) var(--s-2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||
}
|
||||
.rail__btn:hover, .rail__btn:focus-visible {
|
||||
color: var(--text);
|
||||
}
|
||||
.rail__btn.active {
|
||||
color: var(--orange);
|
||||
border-bottom-color: var(--orange);
|
||||
}
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.icon :global(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.label { letter-spacing: 0.04em; }
|
||||
</style>
|
||||
@@ -0,0 +1,496 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
fetchPost,
|
||||
likePost,
|
||||
unlikePost,
|
||||
repostPost,
|
||||
unrepostPost,
|
||||
session,
|
||||
showError,
|
||||
type Post,
|
||||
} from "../api/client";
|
||||
import EmbedImage from "./EmbedImage.svelte";
|
||||
import EmbedExternal from "./EmbedExternal.svelte";
|
||||
import { localStorageKey, useLocalStorage } from "../utils/localstorage";
|
||||
|
||||
type Props = { post: Post; on_thread_click?: (uri: string) => void };
|
||||
let { post, on_thread_click }: Props = $props();
|
||||
|
||||
// Quoted-post cache. When the post's embed is a `record`, we fetch
|
||||
// it once on mount and cache it keyed by URI so navigating
|
||||
// timeline → profile doesn't re-fetch the same quote.
|
||||
let quoted: Post | null = $state(null);
|
||||
let quotedErr: string | null = $state(null);
|
||||
let quotedLoading: boolean = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia")
|
||||
? post.embed?.record
|
||||
: null;
|
||||
if (rec?.uri && !quoted && !quotedLoading) {
|
||||
quotedLoading = true;
|
||||
fetchPost(rec.uri)
|
||||
.then((r) => {
|
||||
quoted = r.post;
|
||||
})
|
||||
.catch((e) => {
|
||||
quotedErr = String(e);
|
||||
})
|
||||
.finally(() => {
|
||||
quotedLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the embed shape once at render time. We sniff $type to
|
||||
// decide which sub-component to mount; an unrecognised $type still
|
||||
// renders the post body, just without any embed.
|
||||
const embedKind = $derived.by(() => {
|
||||
if (!post.embed) return "none";
|
||||
switch (post.embed.$type) {
|
||||
case "app.bsky.embed.images": return "images";
|
||||
case "app.bsky.embed.external": return "external";
|
||||
case "app.bsky.embed.record": return "record";
|
||||
case "app.bsky.embed.recordWithMedia": return "recordWithMedia";
|
||||
default: return "unknown";
|
||||
}
|
||||
});
|
||||
|
||||
const replyParentHandle = $derived(post.parent_uri ? post.handle : "");
|
||||
const isReply = $derived(!!post.parent_uri);
|
||||
const isInThread = $derived(
|
||||
!!post.parent_uri &&
|
||||
!!post.root_uri &&
|
||||
post.parent_uri !== post.root_uri
|
||||
);
|
||||
|
||||
// -- engagement (like / repost) ----------------------------------------
|
||||
//
|
||||
// Counts come from the AppView's `GET /api/post/{uri}` response
|
||||
// (`like_count` / `repost_count`). The home timeline doesn't
|
||||
// return them yet, so the buttons fall back to 0. A future change
|
||||
// can pipe the counts into the timeline feed.
|
||||
//
|
||||
// The "active" state (did I like this?) is local: we don't have
|
||||
// `viewer_liked` on the wire. We track it optimistically — the
|
||||
// flag flips the moment the user clicks, and reverts on a server
|
||||
// error.
|
||||
//
|
||||
// Phase 6b: persist the optimistic state (both `liked` and the
|
||||
// backend's `likedUri`) through a reload. Without persistence the
|
||||
// user would re-login and find every like reverted — which reads
|
||||
// to them as "everything got unliked while you were away". We
|
||||
// store under a `${did}:${rkey}` key so different posts don't
|
||||
// stomp each other. The `useLocalStorage` helper is invoked
|
||||
// inside `$effect.pre` so the key changes track changes to
|
||||
// `post.did` / `post.rkey`.
|
||||
let liked: boolean = $state(false);
|
||||
let likedUri: string | null = $state(null);
|
||||
let reposts: boolean = $state(false);
|
||||
let repostUri: string | null = $state(null);
|
||||
let likeBusy: boolean = $state(false);
|
||||
let repostBusy: boolean = $state(false);
|
||||
|
||||
// Hydrate + persist the like state. The box is bound inside an
|
||||
// `$effect.pre` so the key updates whenever `post.did` /
|
||||
// `post.rkey` change (e.g. navigating from one timeline card to
|
||||
// the next).
|
||||
let likedBox: ReturnType<typeof useLocalStorage<{ liked: boolean; uri: string | null }>> | null =
|
||||
$state(null);
|
||||
$effect.pre(() => {
|
||||
const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
|
||||
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, {
|
||||
liked: false,
|
||||
uri: null,
|
||||
});
|
||||
const stored = likedBox.get();
|
||||
liked = stored.liked;
|
||||
likedUri = stored.uri;
|
||||
});
|
||||
$effect(() => {
|
||||
if (!likedBox) return;
|
||||
likedBox.set({ liked, uri: likedUri });
|
||||
});
|
||||
|
||||
// Pull the latest like/repost counts whenever the post changes.
|
||||
// We don't request `thread` data — the simple shape is enough.
|
||||
// Skip the fetch when the post is null (shouldn't happen for a
|
||||
// card on screen, but harmless).
|
||||
$effect(() => {
|
||||
if (!post.uri) return;
|
||||
fetchPost(post.uri)
|
||||
.then((r) => {
|
||||
// The fetchPost response includes the counts when the post
|
||||
// is found. We read them off and seed the local counter.
|
||||
if (r.like_count != null) likeCount = r.like_count;
|
||||
if (r.repost_count != null) repostCount = r.repost_count;
|
||||
})
|
||||
.catch(() => {
|
||||
// Network or AppView outage — keep whatever we had. The
|
||||
// user can still click the button; counts will resolve
|
||||
// next time the post is rehydrated.
|
||||
});
|
||||
});
|
||||
|
||||
let likeCount: number = $state(0);
|
||||
let repostCount: number = $state(0);
|
||||
|
||||
// Whether the buttons are interactive. We disable them when the
|
||||
// user isn't logged in — anonymous users can read the timeline
|
||||
// but not engage.
|
||||
let authed: boolean = $state(false);
|
||||
$effect(() => {
|
||||
const u = $session;
|
||||
authed = !!u;
|
||||
});
|
||||
|
||||
async function onLikeClick() {
|
||||
if (!authed || likeBusy) return;
|
||||
if (!post.uri || !post.cid) return;
|
||||
likeBusy = true;
|
||||
// Optimistic flip.
|
||||
const wasLiked = liked;
|
||||
const prevCount = likeCount;
|
||||
liked = !wasLiked;
|
||||
likeCount = Math.max(0, likeCount + (wasLiked ? -1 : 1));
|
||||
try {
|
||||
if (wasLiked) {
|
||||
if (!likedUri) {
|
||||
// We have no record of the like URI (e.g. the user
|
||||
// reloaded the page mid-state). Roll back and tell them.
|
||||
liked = wasLiked;
|
||||
likeCount = prevCount;
|
||||
showError("can't unlike: missing like URI");
|
||||
return;
|
||||
}
|
||||
await unlikePost(likedUri);
|
||||
likedUri = null;
|
||||
} else {
|
||||
const r = await likePost(post.uri, post.cid);
|
||||
likedUri = r.uri;
|
||||
}
|
||||
} catch (e) {
|
||||
// Roll back on any failure — the user can retry.
|
||||
liked = wasLiked;
|
||||
likeCount = prevCount;
|
||||
showError(`like failed: ${e}`);
|
||||
} finally {
|
||||
likeBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRepostClick() {
|
||||
if (!authed || repostBusy) return;
|
||||
if (!post.uri || !post.cid) return;
|
||||
repostBusy = true;
|
||||
const wasReposted = reposts;
|
||||
const prevCount = repostCount;
|
||||
reposts = !wasReposted;
|
||||
repostCount = Math.max(0, repostCount + (wasReposted ? -1 : 1));
|
||||
try {
|
||||
if (wasReposted) {
|
||||
if (!repostUri) {
|
||||
reposts = wasReposted;
|
||||
repostCount = prevCount;
|
||||
showError("can't unrepost: missing repost URI");
|
||||
return;
|
||||
}
|
||||
await unrepostPost(repostUri);
|
||||
repostUri = null;
|
||||
} else {
|
||||
const r = await repostPost(post.uri, post.cid);
|
||||
repostUri = r.uri;
|
||||
}
|
||||
} catch (e) {
|
||||
reposts = wasReposted;
|
||||
repostCount = prevCount;
|
||||
showError(`repost failed: ${e}`);
|
||||
} finally {
|
||||
repostBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shortCid(c: string) {
|
||||
return c.length > 12 ? c.slice(0, 6) + "…" + c.slice(-4) : c;
|
||||
}
|
||||
function shortHandle(h: string) {
|
||||
if (!h) return "unknown";
|
||||
return h.length > 22 ? h.slice(0, 18) + "…" : h;
|
||||
}
|
||||
function shortDid(d: string) {
|
||||
return d.length > 22 ? d.slice(0, 14) + "…" + d.slice(-4) : d;
|
||||
}
|
||||
function timeAgo(iso: string) {
|
||||
try {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||||
return `${Math.floor(s / 86400)}d`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
function handleThreadClick() {
|
||||
if (post.root_uri && on_thread_click) {
|
||||
on_thread_click(post.root_uri);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="post">
|
||||
{#if isReply || isInThread}
|
||||
<div class="thread-ctx">
|
||||
{#if isInThread}
|
||||
<button
|
||||
class="thread-ctx__link"
|
||||
type="button"
|
||||
onclick={handleThreadClick}
|
||||
title={`open thread root: ${post.root_uri}`}
|
||||
>🧵 thread</button>
|
||||
<span class="thread-ctx__sep">·</span>
|
||||
{/if}
|
||||
{#if isReply && post.parent_uri}
|
||||
<span class="thread-ctx__reply">
|
||||
↩ in reply to
|
||||
<a class="thread-ctx__handle" href={`/profile/${replyParentHandle}`}>
|
||||
@{shortHandle(replyParentHandle)}
|
||||
</a>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<header class="post__head">
|
||||
<span class="prompt">></span>
|
||||
<a class="handle" href={`/profile/${post.handle}`}>@{shortHandle(post.handle)}</a>
|
||||
<span class="time">{timeAgo(post.created_at)}</span>
|
||||
<span class="cid" title={post.cid}>cid: {shortCid(post.cid)}</span>
|
||||
<span class="did" title={post.did}>{shortDid(post.did)}</span>
|
||||
</header>
|
||||
|
||||
<p class="post__body">{post.text}</p>
|
||||
|
||||
{#if embedKind === "images" && post.embed?.images}
|
||||
<div class="embed-grid">
|
||||
{#each post.embed.images as img, i (i)}
|
||||
<EmbedImage image={img} did={post.did} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if embedKind === "external" && post.embed?.external}
|
||||
<EmbedExternal external={post.embed.external} />
|
||||
{:else if embedKind === "record" || embedKind === "recordWithMedia"}
|
||||
{#if post.embed?.record}
|
||||
<blockquote class="quote">
|
||||
<div class="quote__head">
|
||||
<span class="quote__label">quoted</span>
|
||||
<span class="quote__uri">{post.embed.record.uri}</span>
|
||||
</div>
|
||||
{#if quotedLoading}
|
||||
<div class="quote__loading">loading…</div>
|
||||
{:else if quoted}
|
||||
<p class="quote__body">{quoted.text}</p>
|
||||
<div class="quote__meta">
|
||||
<a class="quote__handle" href={`/profile/${quoted.handle}`}>@{shortHandle(quoted.handle)}</a>
|
||||
<span class="quote__time">{timeAgo(quoted.created_at)}</span>
|
||||
</div>
|
||||
{:else if quotedErr}
|
||||
<div class="quote__err">couldn't fetch quoted post: {quotedErr}</div>
|
||||
{/if}
|
||||
</blockquote>
|
||||
{/if}
|
||||
{#if embedKind === "recordWithMedia" && post.embed?.media}
|
||||
{#if post.embed.media.images}
|
||||
<div class="embed-grid">
|
||||
{#each post.embed.media.images as img, i (i)}
|
||||
<EmbedImage image={img} did={post.did} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if post.embed.media.external}
|
||||
<EmbedExternal external={post.embed.media.external} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<footer class="post__foot">
|
||||
<span class="dot">·</span>
|
||||
<span class="ago">{timeAgo(post.created_at)}</span>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
class="action"
|
||||
class:action--active={liked}
|
||||
type="button"
|
||||
onclick={onLikeClick}
|
||||
disabled={!authed || likeBusy}
|
||||
title={!authed ? "log in to like" : liked ? "unlike" : "like"}
|
||||
>
|
||||
<span class="action__icon">{liked ? "♥" : "♡"}</span>
|
||||
<span class="action__count">{likeCount}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action"
|
||||
class:action--active={reposts}
|
||||
type="button"
|
||||
onclick={onRepostClick}
|
||||
disabled={!authed || repostBusy}
|
||||
title={!authed ? "log in to repost" : reposts ? "unrepost" : "repost"}
|
||||
>
|
||||
<span class="action__icon">{reposts ? "⇆" : "↻"}</span>
|
||||
<span class="action__count">{repostCount}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.post {
|
||||
padding: var(--s-4) var(--s-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: background var(--dur) var(--ease);
|
||||
}
|
||||
.post:hover { background: rgba(255, 102, 0, 0.02); }
|
||||
.thread-ctx {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--s-2);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
}
|
||||
.thread-ctx__link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--orange);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
.thread-ctx__link:hover { text-decoration: underline; }
|
||||
.thread-ctx__sep { color: var(--line-2); }
|
||||
.thread-ctx__reply { color: var(--text-dim); }
|
||||
.thread-ctx__handle { color: var(--text); }
|
||||
.thread-ctx__handle:hover { color: var(--orange); }
|
||||
.post__head {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
margin-bottom: var(--s-2);
|
||||
}
|
||||
.prompt { color: var(--orange); }
|
||||
.handle { color: var(--text); }
|
||||
.handle:hover { color: var(--orange); }
|
||||
.time, .cid, .did { color: var(--cid-fg); }
|
||||
.did { color: var(--text-dim); }
|
||||
.post__body {
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 var(--s-3);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.post__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
}
|
||||
.dot { color: var(--line-2); }
|
||||
.ago { color: var(--text-dim); }
|
||||
.spacer { flex: 1; }
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
background: transparent;
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: color var(--dur) var(--ease),
|
||||
border-color var(--dur) var(--ease),
|
||||
background var(--dur) var(--ease);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.action:hover:not(:disabled) {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
}
|
||||
.action:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.action--active {
|
||||
color: var(--orange);
|
||||
border-color: var(--orange);
|
||||
background: rgba(255, 102, 0, 0.06);
|
||||
}
|
||||
.action__icon {
|
||||
font-size: var(--fs-100);
|
||||
line-height: 1;
|
||||
}
|
||||
.embed-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
.quote {
|
||||
margin: var(--s-2) 0 var(--s-3);
|
||||
padding: var(--s-3);
|
||||
border-left: 2px solid var(--orange-25);
|
||||
background: var(--bg-elev);
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
}
|
||||
.quote__head {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--s-2);
|
||||
}
|
||||
.quote__label { color: var(--orange); }
|
||||
.quote__uri {
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.quote__loading, .quote__err {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.quote__err { color: var(--red); }
|
||||
.quote__body {
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-100);
|
||||
line-height: var(--lh-body);
|
||||
margin: 0 0 var(--s-2);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.quote__meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.quote__handle { color: var(--text); }
|
||||
.quote__handle:hover { color: var(--orange); }
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
let { rows = 3 }: { rows?: number } = $props();
|
||||
let visibleRows = $derived(Math.max(1, rows));
|
||||
</script>
|
||||
|
||||
<div class="skel" aria-busy="true" aria-live="polite">
|
||||
{#each Array.from({ length: visibleRows }) as _, i (i)}
|
||||
<div class="skel__head">
|
||||
<span class="skel__bar skel__bar--xs"></span>
|
||||
<span class="skel__bar skel__bar--sm"></span>
|
||||
<span class="skel__bar skel__bar--md"></span>
|
||||
</div>
|
||||
<div class="skel__body">
|
||||
<span class="skel__bar skel__bar--lg"></span>
|
||||
<span class="skel__bar skel__bar--lg"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.skel {
|
||||
padding: var(--s-4) var(--s-5);
|
||||
}
|
||||
.skel__head,
|
||||
.skel__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
.skel__head { align-items: center; margin-bottom: var(--s-2); }
|
||||
.skel__body {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
.skel__bar {
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-elev) 0%,
|
||||
var(--line-2) 50%,
|
||||
var(--bg-elev) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skel__bar--xs { width: 18px; height: 10px; }
|
||||
.skel__bar--sm { width: 96px; }
|
||||
.skel__bar--md { width: 140px; }
|
||||
.skel__bar--lg { width: 100%; height: 14px; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skel__bar { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
type Mode = "NORMAL" | "INSERT" | "COMPOSE";
|
||||
type Health = "ok" | "warn" | "err";
|
||||
|
||||
let { did = "", authenticated = false }: { did?: string; authenticated?: boolean } = $props();
|
||||
|
||||
let mode: Mode = $state("NORMAL");
|
||||
let pds: Health = $state("ok");
|
||||
let rev: number = $state(0);
|
||||
let lagMs: number = $state(1200);
|
||||
let now: string = $state("");
|
||||
let timer: number | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const update = () => {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
now = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
};
|
||||
update();
|
||||
timer = window.setInterval(update, 1000);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
function lagColor(l: number) {
|
||||
if (l < 3000) return "var(--lag-ok)";
|
||||
if (l < 8000) return "var(--lag-warn)";
|
||||
return "var(--red)";
|
||||
}
|
||||
|
||||
function shortDid(d: string) {
|
||||
if (!d) return "did:plc:not-logged-in";
|
||||
if (d.length > 24) return d.slice(0, 14) + "…" + d.slice(-4);
|
||||
return d;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="statusbar" data-mode={mode}>
|
||||
<div class="cluster">
|
||||
<span class="mode">MODE:{mode}</span>
|
||||
<span class="dot dot-{pds}"></span>
|
||||
<span>PDS:{pds}</span>
|
||||
<span>auth:<b class:auth-ok={authenticated} class:auth-off={!authenticated}>{authenticated ? "ok" : "off"}</b></span>
|
||||
<span>rev:<b class="rev">{rev}</b></span>
|
||||
<span style="color: {lagColor(lagMs)}">lag:{lagMs}ms</span>
|
||||
<span class="did" title={did}>did:{shortDid(did)}</span>
|
||||
</div>
|
||||
<div class="time">{now}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statusbar {
|
||||
height: 24px;
|
||||
background: var(--bg-elev);
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--s-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
.cluster > * { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.mode { color: var(--orange); }
|
||||
.rev { color: var(--rev-fg); }
|
||||
.auth-ok { color: var(--lag-ok); }
|
||||
.auth-off { color: var(--red); }
|
||||
.did { color: var(--cid-fg); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dot { width: 7px; height: 7px; border-radius: var(--r-pill); }
|
||||
.dot-ok { background: var(--lag-ok); animation: pulse 1.6s ease-in-out infinite; }
|
||||
.dot-warn { background: var(--lag-warn); }
|
||||
.dot-err { background: var(--red); }
|
||||
.time { color: var(--text-dim); }
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--lag-ok); }
|
||||
50% { box-shadow: 0 0 0 5px transparent; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
let { children, title = "maarcadetweet" }: { children?: any; title?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="terminal__bar">
|
||||
<div class="terminal__dots">
|
||||
<i></i><i></i><i></i>
|
||||
</div>
|
||||
<div class="terminal__title">{title}</div>
|
||||
<div class="terminal__bar-spacer"></div>
|
||||
</div>
|
||||
<div class="terminal__body">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.terminal__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.terminal__dots {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
.terminal__dots i {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--line-2);
|
||||
display: block;
|
||||
}
|
||||
.terminal__dots i:first-child { background: #4a4a4a; }
|
||||
.terminal__title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
color: var(--text-dim);
|
||||
margin-inline: auto;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.terminal__bar-spacer { width: 36px; }
|
||||
.terminal__body {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.85;
|
||||
padding: var(--s-5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
:root {
|
||||
--bg: #0D0D0D;
|
||||
--bg-elev: #1A1A1A;
|
||||
--bg-deep: #0A0A0A;
|
||||
--line: #2A2A2A;
|
||||
--line-2: #3A3A3A;
|
||||
|
||||
--text: #E8E8E8;
|
||||
--text-dim: #888888;
|
||||
|
||||
--orange: #FF6600;
|
||||
--orange-bright: #FF9500;
|
||||
--orange-3: rgba(255, 102, 0, 0.03);
|
||||
--orange-8: rgba(255, 102, 0, 0.08);
|
||||
--orange-25: rgba(255, 102, 0, 0.25);
|
||||
--orange-glow: rgba(255, 102, 0, 0.28);
|
||||
|
||||
--green: #00FF41;
|
||||
--cyan: #00D4FF;
|
||||
--red: #FF3B30;
|
||||
|
||||
--cid-fg: #B8B8B8;
|
||||
--rev-fg: #FF9500;
|
||||
--lag-ok: #00FF41;
|
||||
--lag-warn: #FFB000;
|
||||
|
||||
--font-mono: "IBMPlexMono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
--font-sans: "NotoSans", system-ui, -apple-system, sans-serif;
|
||||
|
||||
--fs-50: 0.8rem;
|
||||
--fs-100: 1rem;
|
||||
--fs-200: 1.25rem;
|
||||
--fs-300: 1.563rem;
|
||||
--fs-400: 1.953rem;
|
||||
--fs-500: 2.441rem;
|
||||
--fs-600: 3.052rem;
|
||||
--fs-700: 3.815rem;
|
||||
|
||||
--lh-tight: 1.05;
|
||||
--lh-snug: 1.25;
|
||||
--lh-body: 1.6;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-label: 0.08em;
|
||||
|
||||
--s-1: 0.25rem;
|
||||
--s-2: 0.5rem;
|
||||
--s-3: 0.75rem;
|
||||
--s-4: 1rem;
|
||||
--s-5: 1.5rem;
|
||||
--s-6: 2rem;
|
||||
--s-7: 3rem;
|
||||
--s-8: 4rem;
|
||||
--s-9: 6rem;
|
||||
--s-10: 8rem;
|
||||
|
||||
--maxw: 1140px;
|
||||
--grid-size: 28px;
|
||||
|
||||
--r-sm: 4px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
--r-pill: 999px;
|
||||
|
||||
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
--dur: 170ms;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Unit tests for `localstorage.ts`. Run with:
|
||||
// npx vitest run src/lib/utils/localstorage.test.ts
|
||||
// or, if vitest isn't installed yet:
|
||||
// node --test --experimental-strip-types src/lib/utils/localstorage.test.ts
|
||||
//
|
||||
// We mock `localStorage` per-test with an in-memory shim so the
|
||||
// tests are deterministic and don't touch the host's actual
|
||||
// `localStorage`.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { localStorageKey, useLocalStorage } from "./localstorage";
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
getItem(key: string): string | null {
|
||||
return this.store.has(key) ? (this.store.get(key) as string) : null;
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.store.keys())[index] ?? null;
|
||||
}
|
||||
get length(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
}
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
let memory: MemoryStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
memory = new MemoryStorage();
|
||||
vi.stubGlobal("localStorage", memory);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns the initial value when storage is empty", () => {
|
||||
const box = useLocalStorage<{ count: number }>("k1", { count: 0 });
|
||||
expect(box.get()).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it("reads an existing JSON value from storage on construction", () => {
|
||||
memory.setItem("k2", JSON.stringify({ count: 7 }));
|
||||
const box = useLocalStorage<{ count: number }>("k2", { count: 0 });
|
||||
expect(box.get()).toEqual({ count: 7 });
|
||||
});
|
||||
|
||||
it("falls back to initial on a malformed JSON value", () => {
|
||||
memory.setItem("k3", "{not json");
|
||||
const box = useLocalStorage<number>("k3", 99);
|
||||
expect(box.get()).toBe(99);
|
||||
});
|
||||
|
||||
it("writes JSON-encoded values to storage on set", () => {
|
||||
const box = useLocalStorage<string[]>("k4", []);
|
||||
box.set(["a", "b"]);
|
||||
expect(memory.getItem("k4")).toBe(JSON.stringify(["a", "b"]));
|
||||
});
|
||||
|
||||
it("notifies subscribers when set() is called", () => {
|
||||
const box = useLocalStorage<number>("k5", 0);
|
||||
const seen: number[] = [];
|
||||
const unsub = box.subscribe((v) => seen.push(v));
|
||||
box.set(1);
|
||||
box.set(2);
|
||||
unsub();
|
||||
box.set(3);
|
||||
expect(seen).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("treats missing localStorage as a no-op (in-memory only)", () => {
|
||||
vi.stubGlobal("localStorage", undefined);
|
||||
const box = useLocalStorage<boolean>("k6", false);
|
||||
expect(box.get()).toBe(false);
|
||||
box.set(true);
|
||||
expect(box.get()).toBe(true);
|
||||
// No throw means success.
|
||||
});
|
||||
|
||||
it("swallows subscriber errors so the rest keep firing", () => {
|
||||
const box = useLocalStorage<string>("k7", "init");
|
||||
const seen: string[] = [];
|
||||
box.subscribe(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
box.subscribe((v) => seen.push(v));
|
||||
box.set("after");
|
||||
expect(seen).toEqual(["after"]);
|
||||
});
|
||||
|
||||
it("preserves identity for the same key after re-instantiation", () => {
|
||||
const a = useLocalStorage<{ v: number }>("k8", { v: 1 });
|
||||
a.set({ v: 42 });
|
||||
const b = useLocalStorage<{ v: number }>("k8", { v: 1 });
|
||||
expect(b.get()).toEqual({ v: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("localStorageKey", () => {
|
||||
it("prefixes with the project namespace", () => {
|
||||
expect(localStorageKey("liked:did:rkey")).toBe(
|
||||
"maarcadetweet:liked:did:rkey",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/// `localStorage`-backed reactive primitive.
|
||||
///
|
||||
/// We use this for tiny UI-only state that doesn't need to round
|
||||
/// trip the PDS (like "did the current viewer like this post").
|
||||
///
|
||||
/// The hook is intentionally minimal:
|
||||
/// * Reads `localStorage.getItem(key)` once on construction and
|
||||
/// exposes the value through a `get()` accessor and a Svelte
|
||||
/// `subscribe` rune via the `subscribe(fn)` form. Components
|
||||
/// that want fine-grained reactivity can call `get()` inside
|
||||
/// an `$effect` or `$derived`.
|
||||
/// * `set(value)` writes to `localStorage` and notifies any
|
||||
/// subscribers.
|
||||
/// * On the server (no `window`) everything is a no-op and the
|
||||
/// value defaults to `initial`.
|
||||
///
|
||||
/// We deliberately don't use sessionStorage and don't encrypt — the
|
||||
/// keys are namespaced with `maarcadetweet:` and the values are
|
||||
/// URIs/CIDs, not credentials.
|
||||
|
||||
export type Listener<T> = (value: T) => void;
|
||||
|
||||
export interface LocalStorageBox<T> {
|
||||
/** Read the current value synchronously. */
|
||||
get(): T;
|
||||
/** Write a new value (also persisted if storage is available). */
|
||||
set(value: T): void;
|
||||
/** Subscribe to future changes; returns an unsubscribe fn. */
|
||||
subscribe(fn: Listener<T>): () => void;
|
||||
}
|
||||
|
||||
function safeParse<T>(raw: string | null, fallback: T): T {
|
||||
if (raw == null) return fallback;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function hasStorage(): boolean {
|
||||
try {
|
||||
return typeof globalThis !== "undefined"
|
||||
&& typeof (globalThis as { localStorage?: Storage }).localStorage !== "undefined";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `useLocalStorage(key, initial)` — persist a JSON-serialisable value
|
||||
* in `localStorage` under `key`.
|
||||
*
|
||||
* Behaviour:
|
||||
* * SSR-safe: when `localStorage` is unavailable the box still
|
||||
* behaves as an in-memory holder (writes are dropped on reload,
|
||||
* which is correct for SSR).
|
||||
* * Parse errors fall back to `initial` rather than throwing —
|
||||
* stale or corrupted entries shouldn't crash the UI.
|
||||
* * `set(value)` writes synchronously and notifies subscribers
|
||||
* before returning; subscribers are invoked in subscription
|
||||
* order, and exceptions are caught so a single bad listener
|
||||
* doesn't break the rest.
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initial: T,
|
||||
): LocalStorageBox<T> {
|
||||
const storage = hasStorage() ? globalThis.localStorage : null;
|
||||
const listeners = new Set<Listener<T>>();
|
||||
|
||||
const stored = storage ? safeParse<T>(storage.getItem(key), initial) : initial;
|
||||
let current: T = stored;
|
||||
|
||||
const notify = (value: T) => {
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn(value);
|
||||
} catch (e) {
|
||||
// A subscriber threw; swallow and keep going so the UI
|
||||
// doesn't end up half-updated.
|
||||
console.error("useLocalStorage subscriber error", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get() {
|
||||
return current;
|
||||
},
|
||||
set(value: T) {
|
||||
current = value;
|
||||
if (storage) {
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
// Quota exceeded / private mode — fall through to the
|
||||
// in-memory copy so the rest of the app keeps working
|
||||
// this session.
|
||||
console.error("useLocalStorage write failed", e);
|
||||
}
|
||||
}
|
||||
notify(value);
|
||||
},
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Namespacing helper so component authors don't have to remember
|
||||
* the project prefix. */
|
||||
export function localStorageKey(suffix: string): string {
|
||||
return `maarcadetweet:${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
// Tray + notification event wiring. These are emitted by the Rust
|
||||
// side from `setup()` in `lib.rs` — see `TrayIconBuilder::on_menu_event`
|
||||
// and the `show_notification` Tauri command. We keep the listeners
|
||||
// here (instead of inside App.svelte) so they live for the full
|
||||
// lifetime of the page, not the current view.
|
||||
|
||||
const unlisteners: UnlistenFn[] = [];
|
||||
|
||||
async function wireBackendEvents() {
|
||||
// "Show maarcadetweet" tray menu item, or a left click on the
|
||||
// tray icon. We focus the main window — Tauri 2 has no
|
||||
// `WindowExt::show()` shortcut, so we look it up by label.
|
||||
unlisteners.push(
|
||||
await listen("app://show", async () => {
|
||||
try {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
const w = getCurrentWindow();
|
||||
await w.show();
|
||||
await w.setFocus();
|
||||
} catch (e) {
|
||||
console.warn("tray show failed", e);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// "Compose" tray menu item — open the compose view.
|
||||
unlisteners.push(
|
||||
await listen("app://compose", () => {
|
||||
const ev = new CustomEvent("maarcadetweet:navigate", {
|
||||
detail: { view: "compose" },
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
})
|
||||
);
|
||||
|
||||
// OS notification click — payload includes a `url` the user wants
|
||||
// us to navigate to (e.g. "at://did/.../..."). For now we just
|
||||
// show a toast via the same custom event the Svelte side listens
|
||||
// to; a follow-up can route to the post.
|
||||
unlisteners.push(
|
||||
await listen<{ title: string; body: string; url: string | null }>(
|
||||
"app://notification",
|
||||
(event) => {
|
||||
console.log("notification event", event.payload);
|
||||
const ev = new CustomEvent("maarcadetweet:notification", {
|
||||
detail: event.payload,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
wireBackendEvents().catch((e) => {
|
||||
// Non-fatal: this fails outside a Tauri WebView (e.g. when the app
|
||||
// is served by `vite dev` for browser-only preview). Log so we
|
||||
// notice but don't break the dev experience.
|
||||
console.warn("tauri backend event wiring skipped:", e);
|
||||
});
|
||||
|
||||
// Cleanup on hot-reload (vite dev). Unlisteners are stored
|
||||
// module-globally so the previous teardown runs first.
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
for (const u of unlisteners) u();
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [
|
||||
svelte(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{ src: "src/assets/fonts/*", dest: "fonts" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1430,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? { protocol: "ws", host, port: 1431 }
|
||||
: undefined,
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_ENV_*"],
|
||||
build: {
|
||||
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user