add 2fa
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use super::oauth;
|
use super::oauth;
|
||||||
@@ -6,20 +8,53 @@ use crate::lexicons::com_atproto_server;
|
|||||||
use crate::types::{CreateSessionRequest, CreateSessionResponse};
|
use crate::types::{CreateSessionRequest, CreateSessionResponse};
|
||||||
use crate::xrpc::XrpcClient;
|
use crate::xrpc::XrpcClient;
|
||||||
|
|
||||||
/// Login to ATProto PDS
|
/// Login to ATProto PDS (with 2FA support)
|
||||||
pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Result<()> {
|
pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Result<()> {
|
||||||
let client = XrpcClient::new(pds);
|
let client = XrpcClient::new(pds);
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
identifier: handle.to_string(),
|
identifier: handle.to_string(),
|
||||||
password: password.to_string(),
|
password: password.to_string(),
|
||||||
|
auth_factor_token: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_type = if is_bot { "bot" } else { "user" };
|
let account_type = if is_bot { "bot" } else { "user" };
|
||||||
println!("Logging in to {} as {} ({})...", pds, handle, account_type);
|
println!("Logging in to {} as {} ({})...", pds, handle, account_type);
|
||||||
|
|
||||||
let session_res: CreateSessionResponse =
|
let session_res = match client
|
||||||
client.call_unauth(&com_atproto_server::CREATE_SESSION, &req).await?;
|
.call_unauth::<_, CreateSessionResponse>(&com_atproto_server::CREATE_SESSION, &req)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(e) => {
|
||||||
|
// Check if 2FA is required
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if err_str.contains("AuthFactorTokenRequired") {
|
||||||
|
eprintln!("2FA is enabled. Check your email for a confirmation code.");
|
||||||
|
eprint!("Enter 2FA code: ");
|
||||||
|
io::stderr().flush()?;
|
||||||
|
let mut code = String::new();
|
||||||
|
io::stdin().read_line(&mut code)?;
|
||||||
|
let code = code.trim().to_string();
|
||||||
|
|
||||||
|
if code.is_empty() {
|
||||||
|
anyhow::bail!("No code entered, aborting.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let req_2fa = CreateSessionRequest {
|
||||||
|
identifier: handle.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
auth_factor_token: Some(code),
|
||||||
|
};
|
||||||
|
|
||||||
|
client
|
||||||
|
.call_unauth(&com_atproto_server::CREATE_SESSION, &req_2fa)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
did: session_res.did,
|
did: session_res.did,
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ pub mod pds;
|
|||||||
pub mod gpt;
|
pub mod gpt;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
|
pub mod twofa;
|
||||||
|
|||||||
294
src/commands/twofa.rs
Normal file
294
src/commands/twofa.rs
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use super::token;
|
||||||
|
use crate::types::{
|
||||||
|
GetSessionResponse, RequestEmailUpdateResponse, UpdateEmailRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Read a line from stdin with a prompt
|
||||||
|
fn prompt_input(msg: &str) -> Result<String> {
|
||||||
|
eprint!("{}", msg);
|
||||||
|
io::stderr().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().read_line(&mut buf)?;
|
||||||
|
Ok(buf.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build XRPC URL
|
||||||
|
fn xrpc_url(pds: &str, nsid: &str) -> String {
|
||||||
|
format!("https://{}/xrpc/{}", pds, nsid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearer GET request (bypasses OAuth/DPoP entirely)
|
||||||
|
async fn bearer_get<T: serde::de::DeserializeOwned>(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
pds: &str,
|
||||||
|
nsid: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<T> {
|
||||||
|
let url = xrpc_url(pds, nsid);
|
||||||
|
let res = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("XRPC GET failed")?;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("XRPC error ({}): {}", status.as_u16(), body);
|
||||||
|
}
|
||||||
|
res.json::<T>().await.context("Failed to parse response")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearer POST request without body
|
||||||
|
async fn bearer_post_empty<T: serde::de::DeserializeOwned>(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
pds: &str,
|
||||||
|
nsid: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<T> {
|
||||||
|
let url = xrpc_url(pds, nsid);
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("XRPC POST failed")?;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("XRPC error ({}): {}", status.as_u16(), body);
|
||||||
|
}
|
||||||
|
res.json::<T>().await.context("Failed to parse response")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearer POST request with JSON body
|
||||||
|
async fn bearer_post<B: serde::Serialize>(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
pds: &str,
|
||||||
|
nsid: &str,
|
||||||
|
token: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<String> {
|
||||||
|
let url = xrpc_url(pds, nsid);
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("XRPC POST failed")?;
|
||||||
|
let status = res.status();
|
||||||
|
let response_body = res.text().await.unwrap_or_default();
|
||||||
|
if !status.is_success() {
|
||||||
|
anyhow::bail!("XRPC error ({}): {}", status.as_u16(), response_body);
|
||||||
|
}
|
||||||
|
Ok(response_body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh legacy session via Bearer token (no DPoP)
|
||||||
|
async fn refresh_legacy(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
pds: &str,
|
||||||
|
refresh_jwt: &str,
|
||||||
|
) -> Result<token::Session> {
|
||||||
|
let url = xrpc_url(pds, "com.atproto.server.refreshSession");
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", refresh_jwt))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Refresh session failed")?;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
let status = res.status();
|
||||||
|
let body = res.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("Refresh failed ({}): {}", status.as_u16(), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RefreshResponse {
|
||||||
|
did: String,
|
||||||
|
handle: String,
|
||||||
|
access_jwt: String,
|
||||||
|
refresh_jwt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let r: RefreshResponse = res.json().await.context("Failed to parse refresh response")?;
|
||||||
|
Ok(token::Session {
|
||||||
|
did: r.did,
|
||||||
|
handle: r.handle,
|
||||||
|
access_jwt: r.access_jwt,
|
||||||
|
refresh_jwt: r.refresh_jwt,
|
||||||
|
pds: Some(pds.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load legacy session, refresh it, and return (session, http client, pds).
|
||||||
|
/// All requests use plain Bearer auth, completely bypassing OAuth/DPoP.
|
||||||
|
async fn get_legacy_session(is_bot: bool) -> Result<(token::Session, reqwest::Client, String)> {
|
||||||
|
let session = if is_bot {
|
||||||
|
token::load_bot_session()?
|
||||||
|
} else {
|
||||||
|
token::load_session()?
|
||||||
|
};
|
||||||
|
let pds = session.pds.as_deref().unwrap_or("bsky.social").to_string();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let refreshed = refresh_legacy(&client, &pds, &session.refresh_jwt).await?;
|
||||||
|
if is_bot {
|
||||||
|
token::save_bot_session(&refreshed)?;
|
||||||
|
} else {
|
||||||
|
token::save_session(&refreshed)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((refreshed, client, pds))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve email: from --email flag, API response, or interactive prompt
|
||||||
|
fn resolve_email(api_email: Option<String>, flag_email: Option<&str>) -> Result<String> {
|
||||||
|
if let Some(e) = flag_email {
|
||||||
|
return Ok(e.to_string());
|
||||||
|
}
|
||||||
|
if let Some(e) = api_email {
|
||||||
|
return Ok(e);
|
||||||
|
}
|
||||||
|
prompt_input("Email not available from session. Enter your email: ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show current 2FA status
|
||||||
|
pub async fn status(is_bot: bool) -> Result<()> {
|
||||||
|
let (session, client, pds) = get_legacy_session(is_bot).await?;
|
||||||
|
|
||||||
|
let info: GetSessionResponse = bearer_get(
|
||||||
|
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
println!("Handle: {}", info.handle);
|
||||||
|
println!("Email: {}", info.email.as_deref().unwrap_or("(not available)"));
|
||||||
|
println!("2FA: {}", if info.email_auth_factor { "enabled" } else { "disabled" });
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable email 2FA
|
||||||
|
pub async fn enable(is_bot: bool, email_flag: Option<&str>) -> Result<()> {
|
||||||
|
let (session, client, pds) = get_legacy_session(is_bot).await?;
|
||||||
|
|
||||||
|
let info: GetSessionResponse = bearer_get(
|
||||||
|
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
if info.email_auth_factor {
|
||||||
|
println!("2FA is already enabled.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = resolve_email(info.email, email_flag)?;
|
||||||
|
|
||||||
|
println!("Requesting confirmation code to {}...", email);
|
||||||
|
let resp: RequestEmailUpdateResponse = bearer_post_empty(
|
||||||
|
&client, &pds, "com.atproto.server.requestEmailUpdate", &session.access_jwt,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
if !resp.token_required {
|
||||||
|
let req = UpdateEmailRequest {
|
||||||
|
email,
|
||||||
|
token: None,
|
||||||
|
email_auth_factor: Some(true),
|
||||||
|
};
|
||||||
|
let res_body = bearer_post(
|
||||||
|
&client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req,
|
||||||
|
).await?;
|
||||||
|
if !res_body.is_empty() {
|
||||||
|
eprintln!("PDS response: {}", res_body);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let token = prompt_input("Enter confirmation code from email: ")?;
|
||||||
|
if token.is_empty() {
|
||||||
|
anyhow::bail!("No code entered, aborting.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = UpdateEmailRequest {
|
||||||
|
email,
|
||||||
|
token: Some(token),
|
||||||
|
email_auth_factor: Some(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res_body = bearer_post(
|
||||||
|
&client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req,
|
||||||
|
).await?;
|
||||||
|
if !res_body.is_empty() {
|
||||||
|
eprintln!("PDS response: {}", res_body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
let check: GetSessionResponse = bearer_get(
|
||||||
|
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
if check.email_auth_factor {
|
||||||
|
println!("2FA enabled.");
|
||||||
|
} else {
|
||||||
|
println!("Warning: PDS accepted the request but 2FA is still disabled.");
|
||||||
|
println!("This PDS may not support email 2FA via updateEmail.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable email 2FA
|
||||||
|
pub async fn disable(is_bot: bool, email_flag: Option<&str>) -> Result<()> {
|
||||||
|
let (session, client, pds) = get_legacy_session(is_bot).await?;
|
||||||
|
|
||||||
|
let info: GetSessionResponse = bearer_get(
|
||||||
|
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
if !info.email_auth_factor {
|
||||||
|
println!("2FA is already disabled.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = resolve_email(info.email, email_flag)?;
|
||||||
|
|
||||||
|
println!("Requesting confirmation code to {}...", email);
|
||||||
|
let _resp: RequestEmailUpdateResponse = bearer_post_empty(
|
||||||
|
&client, &pds, "com.atproto.server.requestEmailUpdate", &session.access_jwt,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let token = prompt_input("Enter confirmation code from email: ")?;
|
||||||
|
if token.is_empty() {
|
||||||
|
anyhow::bail!("No code entered, aborting.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = UpdateEmailRequest {
|
||||||
|
email,
|
||||||
|
token: Some(token),
|
||||||
|
email_auth_factor: Some(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res_body = bearer_post(
|
||||||
|
&client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req,
|
||||||
|
).await?;
|
||||||
|
if !res_body.is_empty() {
|
||||||
|
eprintln!("PDS response: {}", res_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
let check: GetSessionResponse = bearer_get(
|
||||||
|
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
if !check.email_auth_factor {
|
||||||
|
println!("2FA disabled.");
|
||||||
|
} else {
|
||||||
|
println!("Warning: PDS accepted the request but 2FA is still enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
45
src/main.rs
45
src/main.rs
@@ -222,6 +222,38 @@ enum Commands {
|
|||||||
|
|
||||||
/// Initialize config
|
/// Initialize config
|
||||||
Setup,
|
Setup,
|
||||||
|
|
||||||
|
/// Two-factor authentication (email 2FA)
|
||||||
|
#[command(name = "2fa")]
|
||||||
|
TwoFa {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: TwoFaCommands,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum TwoFaCommands {
|
||||||
|
/// Show 2FA status
|
||||||
|
Status {
|
||||||
|
#[arg(long)]
|
||||||
|
bot: bool,
|
||||||
|
},
|
||||||
|
/// Enable email 2FA
|
||||||
|
Enable {
|
||||||
|
#[arg(long)]
|
||||||
|
bot: bool,
|
||||||
|
/// Account email (prompted if not available from session)
|
||||||
|
#[arg(short, long)]
|
||||||
|
email: Option<String>,
|
||||||
|
},
|
||||||
|
/// Disable email 2FA
|
||||||
|
Disable {
|
||||||
|
#[arg(long)]
|
||||||
|
bot: bool,
|
||||||
|
/// Account email (prompted if not available from session)
|
||||||
|
#[arg(short, long)]
|
||||||
|
email: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -395,6 +427,19 @@ async fn main() -> Result<()> {
|
|||||||
Commands::Setup => {
|
Commands::Setup => {
|
||||||
commands::setup::run()?;
|
commands::setup::run()?;
|
||||||
}
|
}
|
||||||
|
Commands::TwoFa { command } => {
|
||||||
|
match command {
|
||||||
|
TwoFaCommands::Status { bot } => {
|
||||||
|
commands::twofa::status(bot).await?;
|
||||||
|
}
|
||||||
|
TwoFaCommands::Enable { bot, email } => {
|
||||||
|
commands::twofa::enable(bot, email.as_deref()).await?;
|
||||||
|
}
|
||||||
|
TwoFaCommands::Disable { bot, email } => {
|
||||||
|
commands::twofa::disable(bot, email.as_deref()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Commands::Gpt { command } => {
|
Commands::Gpt { command } => {
|
||||||
match command {
|
match command {
|
||||||
GptCommands::Core { download } => {
|
GptCommands::Core { download } => {
|
||||||
|
|||||||
36
src/types.rs
36
src/types.rs
@@ -51,9 +51,45 @@ pub struct DescribeRepoResponse {
|
|||||||
|
|
||||||
/// ATProto createSession request
|
/// ATProto createSession request
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CreateSessionRequest {
|
pub struct CreateSessionRequest {
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth_factor_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ATProto requestEmailUpdate response
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RequestEmailUpdateResponse {
|
||||||
|
pub token_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ATProto updateEmail request
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateEmailRequest {
|
||||||
|
pub email: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub email_auth_factor: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ATProto getSession response
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GetSessionResponse {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub did: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub email_confirmed: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email_auth_factor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ATProto createSession / refreshSession response
|
/// ATProto createSession / refreshSession response
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ function getTranslatedContent(msg: ChatMessage): string {
|
|||||||
const translations = msg.value.translations
|
const translations = msg.value.translations
|
||||||
|
|
||||||
if (translations && currentLang !== originalLang && translations[currentLang]) {
|
if (translations && currentLang !== originalLang && translations[currentLang]) {
|
||||||
return translations[currentLang].content || msg.value.content.text
|
const translated = translations[currentLang].content
|
||||||
|
if (typeof translated === 'string') return translated
|
||||||
|
if (translated && typeof translated === 'object' && 'text' in translated) return (translated as { text: string }).text
|
||||||
|
return msg.value.content.text
|
||||||
}
|
}
|
||||||
return msg.value.content.text
|
return msg.value.content.text
|
||||||
}
|
}
|
||||||
@@ -36,8 +39,8 @@ function getRkeyFromUri(uri: string): string {
|
|||||||
return uri.split('/').pop() || ''
|
return uri.split('/').pop() || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract DID from AT URI (at://did:plc:xxx/collection/rkey → did:plc:xxx)
|
// Extract authority from AT URI (at://did:plc:xxx/collection/rkey → did:plc:xxx)
|
||||||
function getDidFromUri(uri: string): string {
|
function getAuthorityFromUri(uri: string): string {
|
||||||
return uri.replace('at://', '').split('/')[0]
|
return uri.replace('at://', '').split('/')[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +51,13 @@ interface AuthorInfo {
|
|||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle/DID resolver: maps both handle and DID to DID
|
||||||
|
let handleToDidMap = new Map<string, string>()
|
||||||
|
|
||||||
|
function resolveAuthorDid(authority: string): string {
|
||||||
|
return handleToDidMap.get(authority) || authority
|
||||||
|
}
|
||||||
|
|
||||||
// Build author info map
|
// Build author info map
|
||||||
function buildAuthorMap(
|
function buildAuthorMap(
|
||||||
userDid: string,
|
userDid: string,
|
||||||
@@ -58,6 +68,13 @@ function buildAuthorMap(
|
|||||||
botProfile?: Profile | null,
|
botProfile?: Profile | null,
|
||||||
pds?: string
|
pds?: string
|
||||||
): Map<string, AuthorInfo> {
|
): Map<string, AuthorInfo> {
|
||||||
|
// Build handle→DID mapping
|
||||||
|
handleToDidMap = new Map<string, string>()
|
||||||
|
handleToDidMap.set(userHandle, userDid)
|
||||||
|
handleToDidMap.set(userDid, userDid)
|
||||||
|
handleToDidMap.set(botHandle, botDid)
|
||||||
|
handleToDidMap.set(botDid, botDid)
|
||||||
|
|
||||||
const authors = new Map<string, AuthorInfo>()
|
const authors = new Map<string, AuthorInfo>()
|
||||||
|
|
||||||
// User info
|
// User info
|
||||||
@@ -79,6 +96,12 @@ function buildAuthorMap(
|
|||||||
return authors
|
return authors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map collection name to chat type slug
|
||||||
|
function chatTypeSlug(chatCollection: string): string {
|
||||||
|
if (chatCollection === 'ai.syui.ue.chat') return 'ue'
|
||||||
|
return 'log'
|
||||||
|
}
|
||||||
|
|
||||||
// Render chat threads list (conversations this user started)
|
// Render chat threads list (conversations this user started)
|
||||||
export function renderChatThreadList(
|
export function renderChatThreadList(
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
@@ -88,7 +111,8 @@ export function renderChatThreadList(
|
|||||||
botHandle: string,
|
botHandle: string,
|
||||||
userProfile?: Profile | null,
|
userProfile?: Profile | null,
|
||||||
botProfile?: Profile | null,
|
botProfile?: Profile | null,
|
||||||
pds?: string
|
pds?: string,
|
||||||
|
chatCollection: string = 'ai.syui.log.chat'
|
||||||
): string {
|
): string {
|
||||||
// Build set of all message URIs
|
// Build set of all message URIs
|
||||||
const allUris = new Set(messages.map(m => m.uri))
|
const allUris = new Set(messages.map(m => m.uri))
|
||||||
@@ -101,7 +125,7 @@ export function renderChatThreadList(
|
|||||||
const rootMessages: ChatMessage[] = []
|
const rootMessages: ChatMessage[] = []
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (getDidFromUri(msg.uri) !== userDid) continue
|
if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue
|
||||||
|
|
||||||
if (!msg.value.root) {
|
if (!msg.value.root) {
|
||||||
// No root = explicit conversation start
|
// No root = explicit conversation start
|
||||||
@@ -132,7 +156,7 @@ export function renderChatThreadList(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const items = sorted.map(msg => {
|
const items = sorted.map(msg => {
|
||||||
const authorDid = getDidFromUri(msg.uri)
|
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||||
const time = formatChatTime(msg.value.publishedAt)
|
const time = formatChatTime(msg.value.publishedAt)
|
||||||
const rkey = getRkeyFromUri(msg.uri)
|
const rkey = getRkeyFromUri(msg.uri)
|
||||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||||
@@ -147,13 +171,14 @@ export function renderChatThreadList(
|
|||||||
const preview = lines.join('\n')
|
const preview = lines.join('\n')
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-thread-item">
|
<a href="/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}" class="chat-thread-item">
|
||||||
<div class="chat-avatar-col">
|
<div class="chat-avatar-col">
|
||||||
${avatarHtml}
|
${avatarHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-thread-content">
|
<div class="chat-thread-content">
|
||||||
<div class="chat-thread-header">
|
<div class="chat-thread-header">
|
||||||
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
||||||
|
<span class="chat-type-badge" data-type="${chatTypeSlug(chatCollection)}">${chatTypeSlug(chatCollection)}</span>
|
||||||
<span class="chat-time">${time}</span>
|
<span class="chat-time">${time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
||||||
@@ -217,7 +242,7 @@ export function renderChatThread(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const items = sorted.map(msg => {
|
const items = sorted.map(msg => {
|
||||||
const authorDid = getDidFromUri(msg.uri)
|
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||||
const time = formatChatTime(msg.value.publishedAt)
|
const time = formatChatTime(msg.value.publishedAt)
|
||||||
const rkey = getRkeyFromUri(msg.uri)
|
const rkey = getRkeyFromUri(msg.uri)
|
||||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||||
@@ -230,7 +255,7 @@ export function renderChatThread(
|
|||||||
const content = renderMarkdown(displayContent)
|
const content = renderMarkdown(displayContent)
|
||||||
const recordLink = `/@${author.handle}/at/collection/${chatCollection}/${rkey}`
|
const recordLink = `/@${author.handle}/at/collection/${chatCollection}/${rkey}`
|
||||||
const canEdit = loggedInDid && authorDid === loggedInDid
|
const canEdit = loggedInDid && authorDid === loggedInDid
|
||||||
const editLink = `/@${userHandle}/at/chat/${rkey}/edit`
|
const editLink = `/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}/edit`
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="chat-message">
|
<article class="chat-message">
|
||||||
@@ -252,9 +277,15 @@ export function renderChatThread(
|
|||||||
return `<div class="chat-list">${items}</div>`
|
return `<div class="chat-list">${items}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render chat list page
|
// Chat collection entry for unified list
|
||||||
|
export interface ChatCollectionEntry {
|
||||||
|
collection: string
|
||||||
|
messages: ChatMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render unified chat list page with filter buttons
|
||||||
export function renderChatListPage(
|
export function renderChatListPage(
|
||||||
messages: ChatMessage[],
|
collections: ChatCollectionEntry[],
|
||||||
userDid: string,
|
userDid: string,
|
||||||
userHandle: string,
|
userHandle: string,
|
||||||
botDid: string,
|
botDid: string,
|
||||||
@@ -263,8 +294,85 @@ export function renderChatListPage(
|
|||||||
botProfile?: Profile | null,
|
botProfile?: Profile | null,
|
||||||
pds?: string
|
pds?: string
|
||||||
): string {
|
): string {
|
||||||
const list = renderChatThreadList(messages, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
// Merge all collections into a single list
|
||||||
return `<div class="chat-container">${list}</div>`
|
const allMessages: ChatMessage[] = []
|
||||||
|
const messageCollectionMap = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const c of collections) {
|
||||||
|
for (const msg of c.messages) {
|
||||||
|
allMessages.push(msg)
|
||||||
|
messageCollectionMap.set(msg.uri, c.collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build author map with first collection's params (all share same users)
|
||||||
|
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||||
|
|
||||||
|
// Find root messages (same logic as renderChatThreadList)
|
||||||
|
const allUris = new Set(allMessages.map(m => m.uri))
|
||||||
|
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
|
||||||
|
const rootMessages: ChatMessage[] = []
|
||||||
|
|
||||||
|
for (const msg of allMessages) {
|
||||||
|
if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue
|
||||||
|
|
||||||
|
if (!msg.value.root) {
|
||||||
|
rootMessages.push(msg)
|
||||||
|
} else if (!allUris.has(msg.value.root)) {
|
||||||
|
const existing = orphanedRootFirstMsg.get(msg.value.root)
|
||||||
|
if (!existing || new Date(msg.value.publishedAt) < new Date(existing.value.publishedAt)) {
|
||||||
|
orphanedRootFirstMsg.set(msg.value.root, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of orphanedRootFirstMsg.values()) {
|
||||||
|
rootMessages.push(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rootMessages.length === 0) {
|
||||||
|
return '<div class="chat-container"><p class="no-posts">No chat threads yet.</p></div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by publishedAt (newest first)
|
||||||
|
const sorted = [...rootMessages].sort((a, b) =>
|
||||||
|
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = sorted.map(msg => {
|
||||||
|
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||||
|
const time = formatChatTime(msg.value.publishedAt)
|
||||||
|
const rkey = getRkeyFromUri(msg.uri)
|
||||||
|
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||||
|
const collection = messageCollectionMap.get(msg.uri) || 'ai.syui.log.chat'
|
||||||
|
const slug = chatTypeSlug(collection)
|
||||||
|
|
||||||
|
const avatarHtml = author.avatarUrl
|
||||||
|
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||||
|
: `<div class="chat-avatar-placeholder"></div>`
|
||||||
|
|
||||||
|
const displayContent = getTranslatedContent(msg)
|
||||||
|
const lines = displayContent.split('\n').slice(0, 3)
|
||||||
|
const preview = lines.join('\n')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="/@${userHandle}/at/chat/${slug}/${rkey}" class="chat-thread-item">
|
||||||
|
<div class="chat-avatar-col">
|
||||||
|
${avatarHtml}
|
||||||
|
</div>
|
||||||
|
<div class="chat-thread-content">
|
||||||
|
<div class="chat-thread-header">
|
||||||
|
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
||||||
|
<span class="chat-type-badge" data-type="${slug}">${slug}</span>
|
||||||
|
<span class="chat-time">${time}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<div class="chat-container"><div class="chat-thread-list">${items}</div></div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render chat thread page
|
// Render chat thread page
|
||||||
@@ -307,7 +415,7 @@ export function renderChatEditForm(
|
|||||||
<div class="chat-edit-footer">
|
<div class="chat-edit-footer">
|
||||||
<span class="chat-edit-collection">${collection}</span>
|
<span class="chat-edit-collection">${collection}</span>
|
||||||
<div class="chat-edit-buttons">
|
<div class="chat-edit-buttons">
|
||||||
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-edit-cancel">Cancel</a>
|
<a href="/@${userHandle}/at/chat/${chatTypeSlug(collection)}/${rkey}" class="chat-edit-cancel">Cancel</a>
|
||||||
<button type="submit" class="chat-edit-save" id="chat-edit-save" data-rkey="${rkey}">Save</button>
|
<button type="submit" class="chat-edit-save" id="chat-edit-save" data-rkey="${rkey}">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,16 +81,16 @@ export function parseRoute(): Route {
|
|||||||
return { type: 'vrm', handle: vrmMatch[1] }
|
return { type: 'vrm', handle: vrmMatch[1] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat edit: /@handle/at/chat/{rkey}/edit
|
// Chat edit: /@handle/at/chat/{type}/{rkey}/edit
|
||||||
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/)
|
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/([^/]+)\/edit$/)
|
||||||
if (chatEditMatch) {
|
if (chatEditMatch) {
|
||||||
return { type: 'chat-edit', handle: chatEditMatch[1], rkey: chatEditMatch[2] }
|
return { type: 'chat-edit', handle: chatEditMatch[1], service: chatEditMatch[2], rkey: chatEditMatch[3] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat thread: /@handle/at/chat/{rkey}
|
// Chat thread: /@handle/at/chat/{type}/{rkey}
|
||||||
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/)
|
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/([^/]+)$/)
|
||||||
if (chatThreadMatch) {
|
if (chatThreadMatch) {
|
||||||
return { type: 'chat-thread', handle: chatThreadMatch[1], rkey: chatThreadMatch[2] }
|
return { type: 'chat-thread', handle: chatThreadMatch[1], service: chatThreadMatch[2], rkey: chatThreadMatch[3] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat list: /@handle/at/chat
|
// Chat list: /@handle/at/chat
|
||||||
@@ -133,10 +133,10 @@ export function navigate(route: Route): void {
|
|||||||
path = `/@${route.handle}/at/card-old`
|
path = `/@${route.handle}/at/card-old`
|
||||||
} else if (route.type === 'chat' && route.handle) {
|
} else if (route.type === 'chat' && route.handle) {
|
||||||
path = `/@${route.handle}/at/chat`
|
path = `/@${route.handle}/at/chat`
|
||||||
} else if (route.type === 'chat-thread' && route.handle && route.rkey) {
|
} else if (route.type === 'chat-thread' && route.handle && route.service && route.rkey) {
|
||||||
path = `/@${route.handle}/at/chat/${route.rkey}`
|
path = `/@${route.handle}/at/chat/${route.service}/${route.rkey}`
|
||||||
} else if (route.type === 'chat-edit' && route.handle && route.rkey) {
|
} else if (route.type === 'chat-edit' && route.handle && route.service && route.rkey) {
|
||||||
path = `/@${route.handle}/at/chat/${route.rkey}/edit`
|
path = `/@${route.handle}/at/chat/${route.service}/${route.rkey}/edit`
|
||||||
} else if (route.type === 'link' && route.handle) {
|
} else if (route.type === 'link' && route.handle) {
|
||||||
path = `/@${route.handle}/at/link`
|
path = `/@${route.handle}/at/link`
|
||||||
} else if (route.type === 'vrm' && route.handle) {
|
} else if (route.type === 'vrm' && route.handle) {
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ async function render(route: Route): Promise<void> {
|
|||||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
|
|
||||||
} else if (route.type === 'chat') {
|
} else if (route.type === 'chat') {
|
||||||
// Chat list page - show threads started by this user
|
// Chat list page - show all chat collections
|
||||||
if (!config.bot) {
|
if (!config.bot) {
|
||||||
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
||||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
@@ -300,16 +300,23 @@ async function render(route: Route): Promise<void> {
|
|||||||
const botHandle = config.bot.handle
|
const botHandle = config.bot.handle
|
||||||
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
|
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
|
||||||
|
|
||||||
// Load messages and profiles in parallel
|
// Load all chat collections in parallel
|
||||||
const [chatMessages, botProfile, pds] = await Promise.all([
|
const [logMessages, ueMessages, botProfile, pds] = await Promise.all([
|
||||||
getChatMessages(did, botDid, chatCollection),
|
getChatMessages(did, botDid, chatCollection),
|
||||||
|
getChatMessages(did, botDid, 'ai.syui.ue.chat'),
|
||||||
getProfile(botDid, false),
|
getProfile(botDid, false),
|
||||||
getPds(did)
|
getPds(did)
|
||||||
])
|
])
|
||||||
|
|
||||||
// Collect available languages from chat messages
|
const chatCollections = [
|
||||||
|
{ collection: chatCollection, messages: logMessages },
|
||||||
|
{ collection: 'ai.syui.ue.chat', messages: ueMessages },
|
||||||
|
].filter(c => c.messages.length > 0)
|
||||||
|
|
||||||
|
// Collect available languages from all chat messages
|
||||||
|
const allChatMsgs = [...logMessages, ...ueMessages]
|
||||||
const chatLangs = new Set<string>()
|
const chatLangs = new Set<string>()
|
||||||
for (const msg of chatMessages) {
|
for (const msg of allChatMsgs) {
|
||||||
const msgLang = msg.value.langs?.[0] || 'ja'
|
const msgLang = msg.value.langs?.[0] || 'ja'
|
||||||
chatLangs.add(msgLang)
|
chatLangs.add(msgLang)
|
||||||
if (msg.value.translations) {
|
if (msg.value.translations) {
|
||||||
@@ -321,21 +328,20 @@ async function render(route: Route): Promise<void> {
|
|||||||
langList = Array.from(chatLangs)
|
langList = Array.from(chatLangs)
|
||||||
|
|
||||||
html += renderLangSelector(langList)
|
html += renderLangSelector(langList)
|
||||||
html += `<div id="content">${renderChatListPage(chatMessages, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}</div>`
|
html += `<div id="content">${renderChatListPage(chatCollections, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}</div>`
|
||||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (route.type === 'chat-thread' && route.rkey) {
|
} else if (route.type === 'chat-thread' && route.service && route.rkey) {
|
||||||
// Chat thread page - show full conversation
|
// Chat thread page - route.service is 'log' or 'ue'
|
||||||
if (!config.bot) {
|
if (!config.bot) {
|
||||||
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
||||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||||
} else {
|
} else {
|
||||||
const botDid = config.bot.did
|
const botDid = config.bot.did
|
||||||
const botHandle = config.bot.handle
|
const botHandle = config.bot.handle
|
||||||
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
|
const chatCollection = route.service === 'ue' ? 'ai.syui.ue.chat' : (config.chatCollection || 'ai.syui.log.chat')
|
||||||
|
|
||||||
// Load messages and profiles in parallel
|
|
||||||
const [chatMessages, botProfile, pds] = await Promise.all([
|
const [chatMessages, botProfile, pds] = await Promise.all([
|
||||||
getChatMessages(did, botDid, chatCollection),
|
getChatMessages(did, botDid, chatCollection),
|
||||||
getProfile(botDid, false),
|
getProfile(botDid, false),
|
||||||
@@ -360,7 +366,7 @@ async function render(route: Route): Promise<void> {
|
|||||||
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (route.type === 'chat-edit' && route.rkey) {
|
} else if (route.type === 'chat-edit' && route.service && route.rkey) {
|
||||||
// Chat edit page
|
// Chat edit page
|
||||||
if (!config.bot) {
|
if (!config.bot) {
|
||||||
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
||||||
@@ -370,9 +376,8 @@ async function render(route: Route): Promise<void> {
|
|||||||
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
||||||
} else {
|
} else {
|
||||||
const botDid = config.bot.did
|
const botDid = config.bot.did
|
||||||
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
|
const chatCollection = route.service === 'ue' ? 'ai.syui.ue.chat' : (config.chatCollection || 'ai.syui.log.chat')
|
||||||
|
|
||||||
// Get the specific message
|
|
||||||
const chatMessages = await getChatMessages(did, botDid, chatCollection)
|
const chatMessages = await getChatMessages(did, botDid, chatCollection)
|
||||||
const targetUri = `at://${did}/${chatCollection}/${route.rkey}`
|
const targetUri = `at://${did}/${chatCollection}/${route.rkey}`
|
||||||
const message = chatMessages.find(m => m.uri === targetUri)
|
const message = chatMessages.find(m => m.uri === targetUri)
|
||||||
@@ -464,6 +469,8 @@ async function render(route: Route): Promise<void> {
|
|||||||
setupVrmPage()
|
setupVrmPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Setup card migration button
|
// Setup card migration button
|
||||||
if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) {
|
if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) {
|
||||||
setupMigrationButton(
|
setupMigrationButton(
|
||||||
|
|||||||
@@ -2576,6 +2576,35 @@ button.tab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chat Type Badge */
|
||||||
|
.chat-type-badge {
|
||||||
|
font-size: 0.7em;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-badge[data-type="log"] {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-badge[data-type="ue"] {
|
||||||
|
background: rgba(168, 85, 247, 0.15);
|
||||||
|
color: #a855f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.chat-type-badge[data-type="log"] {
|
||||||
|
background: rgba(59, 130, 246, 0.25);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-type-badge[data-type="ue"] {
|
||||||
|
background: rgba(168, 85, 247, 0.25);
|
||||||
|
color: #c084fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Chat Thread List */
|
/* Chat Thread List */
|
||||||
.chat-thread-list {
|
.chat-thread-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2612,6 +2641,11 @@ button.tab {
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-thread-header .chat-type-badge {
|
||||||
|
order: 1;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-thread-header .chat-author {
|
.chat-thread-header .chat-author {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
|
|||||||
Reference in New Issue
Block a user