Add built-in attachment markdown html preview
This commit is contained in:
@@ -521,6 +521,31 @@ final class ApiClient {
|
|||||||
return new Models.Attachment(json);
|
return new Models.Attachment(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String downloadAttachmentText(String url) throws Exception {
|
||||||
|
if (url == null || url.trim().isEmpty()) return "";
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) new URL(url.trim()).openConnection();
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
conn.setConnectTimeout(15000);
|
||||||
|
conn.setReadTimeout(30000);
|
||||||
|
conn.setRequestProperty("Accept", "text/markdown,text/html,application/xhtml+xml,application/xml,text/xml,text/*,*/*;q=0.8");
|
||||||
|
conn.setRequestProperty("X-Client-Platform", "android");
|
||||||
|
conn.setRequestProperty("X-Client-Version", "debug");
|
||||||
|
String token = authStore.token();
|
||||||
|
if (token != null && !token.isEmpty() && url.startsWith(BuildConfig.MULTICA_API_BASE_URL)) {
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
}
|
||||||
|
String cookieHeader = authStore.cloudFrontCookieHeader();
|
||||||
|
if (cookieHeader != null && !cookieHeader.isEmpty() && url.contains("static.multica.ai")) {
|
||||||
|
conn.setRequestProperty("Cookie", cookieHeader);
|
||||||
|
}
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
saveResponseCookies(conn);
|
||||||
|
InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
|
||||||
|
String text = readTextPreservingLines(stream);
|
||||||
|
if (code < 200 || code >= 300) throw new ApiException(code, text);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
|
void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
|
||||||
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
|
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
|
||||||
query(null, "workspace_id", workspaceId), null);
|
query(null, "workspace_id", workspaceId), null);
|
||||||
@@ -1506,6 +1531,17 @@ final class ApiClient {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String readTextPreservingLines(InputStream stream) throws Exception {
|
||||||
|
if (stream == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
char[] buffer = new char[4096];
|
||||||
|
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
|
||||||
|
int count;
|
||||||
|
while ((count = reader.read(buffer)) != -1) sb.append(buffer, 0, count);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private static String queryString(JSONObject query) throws Exception {
|
private static String queryString(JSONObject query) throws Exception {
|
||||||
if (query == null || query.length() == 0) return "";
|
if (query == null || query.length() == 0) return "";
|
||||||
StringBuilder sb = new StringBuilder("?");
|
StringBuilder sb = new StringBuilder("?");
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package ai.multica.app;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
final class AttachmentPreview {
|
||||||
|
static final String BASE_URL = "https://attachment-preview.local/";
|
||||||
|
|
||||||
|
enum Kind {
|
||||||
|
MARKDOWN,
|
||||||
|
HTML,
|
||||||
|
XML,
|
||||||
|
DOWNLOAD
|
||||||
|
}
|
||||||
|
|
||||||
|
private AttachmentPreview() {}
|
||||||
|
|
||||||
|
static Kind kindFor(String contentType, String filename) {
|
||||||
|
String type = mediaType(contentType);
|
||||||
|
if (isMarkdownType(type)) return Kind.MARKDOWN;
|
||||||
|
if (isHtmlType(type)) return Kind.HTML;
|
||||||
|
if (isXmlType(type)) return Kind.XML;
|
||||||
|
|
||||||
|
String name = filename == null ? "" : filename.trim().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (name.endsWith(".md") || name.endsWith(".markdown")) return Kind.MARKDOWN;
|
||||||
|
if (name.endsWith(".html") || name.endsWith(".htm")) return Kind.HTML;
|
||||||
|
if (name.endsWith(".xml")) return Kind.XML;
|
||||||
|
return Kind.DOWNLOAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isLocalPreviewUrl(String url) {
|
||||||
|
if (url == null || url.trim().isEmpty()) return false;
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(url.trim());
|
||||||
|
String scheme = uri.getScheme();
|
||||||
|
String host = uri.getHost();
|
||||||
|
return "https".equalsIgnoreCase(scheme) && "attachment-preview.local".equalsIgnoreCase(host);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String xmlPreviewDocument(String xml) {
|
||||||
|
return "<!doctype html><html><head><meta charset=\"utf-8\">"
|
||||||
|
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
||||||
|
+ "<style>body{margin:0;padding:16px;background:#fff;color:#111;font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;}pre{white-space:pre-wrap;word-break:break-word;margin:0;}</style>"
|
||||||
|
+ "</head><body><pre>"
|
||||||
|
+ escapeHtml(xml == null ? "" : xml)
|
||||||
|
+ "</pre></body></html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String mediaType(String contentType) {
|
||||||
|
if (contentType == null) return "";
|
||||||
|
int semicolon = contentType.indexOf(';');
|
||||||
|
String value = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType;
|
||||||
|
return value.trim().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isMarkdownType(String type) {
|
||||||
|
return "text/markdown".equals(type)
|
||||||
|
|| "text/x-markdown".equals(type)
|
||||||
|
|| "application/markdown".equals(type)
|
||||||
|
|| "application/x-markdown".equals(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isHtmlType(String type) {
|
||||||
|
return "text/html".equals(type) || "application/xhtml+xml".equals(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isXmlType(String type) {
|
||||||
|
return "text/xml".equals(type) || "application/xml".equals(type) || type.endsWith("+xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeHtml(String value) {
|
||||||
|
return value.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package ai.multica.app
|
package ai.multica.app
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -12,6 +13,11 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -288,6 +294,22 @@ class ComposePilotActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (intent.getBooleanExtra("preview_attachment_markdown", false)) {
|
||||||
|
setContent {
|
||||||
|
MulticaTheme {
|
||||||
|
PilotAttachmentMarkdownPreviewFixture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (intent.getBooleanExtra("preview_attachment_html", false)) {
|
||||||
|
setContent {
|
||||||
|
MulticaTheme {
|
||||||
|
PilotAttachmentHtmlPreviewFixture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) {
|
if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) {
|
||||||
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
|
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
|
||||||
val previewUser = Models.User(JSONObject()
|
val previewUser = Models.User(JSONObject()
|
||||||
@@ -9360,6 +9382,266 @@ private fun IssueAttachmentsWebPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PilotAttachmentPreview(
|
||||||
|
api: ApiClient,
|
||||||
|
attachment: Models.Attachment,
|
||||||
|
zh: Boolean,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val kind = remember(attachment.contentType, attachment.filename) {
|
||||||
|
AttachmentPreview.kindFor(attachment.contentType, attachment.filename)
|
||||||
|
}
|
||||||
|
val target = remember(attachment.downloadUrl, attachment.url) {
|
||||||
|
absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
|
||||||
|
}
|
||||||
|
var state by remember(attachment.id) { mutableStateOf<Result<String>?>(null) }
|
||||||
|
var refresh by remember(attachment.id) { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
LaunchedEffect(attachment.id, target, refresh) {
|
||||||
|
state = null
|
||||||
|
state = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { api.downloadAttachmentText(target) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MulticaColors.Background)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.semantics { contentDescription = "Attachment Preview Page ${attachment.id}" },
|
||||||
|
) {
|
||||||
|
PilotPageHeader(
|
||||||
|
title = attachment.filename.ifBlank { if (zh) "附件预览" else "Attachment preview" },
|
||||||
|
leading = {
|
||||||
|
MulticaIconPillButton(
|
||||||
|
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||||
|
contentDescription = if (zh) "返回" else "Back",
|
||||||
|
onClick = onBack,
|
||||||
|
tone = MulticaButtonTone.Ghost,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = listOf(attachment.contentType, formatBytes(attachment.sizeBytes))
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" · ")
|
||||||
|
.ifBlank { if (zh) "内置预览" else "Built-in preview" },
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, lineHeight = 17.sp),
|
||||||
|
color = MulticaColors.Muted,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
target.isBlank() -> MulticaErrorState(
|
||||||
|
message = if (zh) "附件没有可预览链接" else "Attachment has no preview URL",
|
||||||
|
onRetry = onBack,
|
||||||
|
fullScreen = false,
|
||||||
|
)
|
||||||
|
state == null -> Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CupertinoActivityIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
state?.isFailure == true -> MulticaErrorState(
|
||||||
|
message = "${if (zh) "附件加载失败" else "Attachment load failed"}\n${state?.exceptionOrNull()?.message.orEmpty()}",
|
||||||
|
onRetry = { refresh++ },
|
||||||
|
fullScreen = false,
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
val body = state?.getOrNull().orEmpty()
|
||||||
|
when (kind) {
|
||||||
|
AttachmentPreview.Kind.MARKDOWN -> AttachmentMarkdownPreview(body)
|
||||||
|
AttachmentPreview.Kind.HTML -> AttachmentHtmlPreview(body, context)
|
||||||
|
AttachmentPreview.Kind.XML -> AttachmentHtmlPreview(AttachmentPreview.xmlPreviewDocument(body), context)
|
||||||
|
AttachmentPreview.Kind.DOWNLOAD -> MulticaErrorState(
|
||||||
|
message = if (zh) "该附件类型不支持内置预览" else "This attachment type does not support built-in preview",
|
||||||
|
onRetry = onBack,
|
||||||
|
fullScreen = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentMarkdownPreview(markdown: String) {
|
||||||
|
SelectionContainer {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
factory = { context ->
|
||||||
|
LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(0, 0, 0, 24)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { view ->
|
||||||
|
view.removeAllViews()
|
||||||
|
MarkdownRenderer.render(
|
||||||
|
view.context,
|
||||||
|
view,
|
||||||
|
markdown,
|
||||||
|
MulticaColors.Text.toArgb(),
|
||||||
|
MulticaColors.Muted.toArgb(),
|
||||||
|
MulticaColors.Border.toArgb(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.AttachmentHtmlPreview(html: String, context: Context) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
factory = { viewContext ->
|
||||||
|
WebView(viewContext).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = false
|
||||||
|
settings.databaseEnabled = false
|
||||||
|
settings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
|
settings.allowFileAccess = false
|
||||||
|
settings.allowContentAccess = false
|
||||||
|
settings.allowFileAccessFromFileURLs = false
|
||||||
|
settings.allowUniversalAccessFromFileURLs = false
|
||||||
|
clearCache(true)
|
||||||
|
clearHistory()
|
||||||
|
webViewClient = AttachmentPreviewWebViewClient(context)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
webView.loadDataWithBaseURL(AttachmentPreview.BASE_URL, html, "text/html", "UTF-8", null)
|
||||||
|
},
|
||||||
|
onRelease = { webView ->
|
||||||
|
webView.stopLoading()
|
||||||
|
webView.clearHistory()
|
||||||
|
webView.clearCache(true)
|
||||||
|
webView.destroy()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AttachmentPreviewWebViewClient(
|
||||||
|
private val context: Context,
|
||||||
|
) : WebViewClient() {
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||||
|
return handleNavigation(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
|
return handleNavigation(request.url?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||||
|
val url = request.url?.toString().orEmpty()
|
||||||
|
return if (AttachmentPreview.isLocalPreviewUrl(url)) null else WebResourceResponse("text/plain", "UTF-8", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNavigation(url: String): Boolean {
|
||||||
|
if (AttachmentPreview.isLocalPreviewUrl(url)) return false
|
||||||
|
openExternalUrl(context, url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PilotAttachmentMarkdownPreviewFixture() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MulticaColors.Background)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
PilotPageHeader(title = "Markdown Attachment Preview")
|
||||||
|
AttachmentMarkdownPreview(
|
||||||
|
"""
|
||||||
|
# Markdown Attachment
|
||||||
|
|
||||||
|
This attachment renders **inside Android** with the existing Markdown renderer.
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| Tables | Ready |
|
||||||
|
| Links | [Multica](https://app.multica.ai) |
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PilotAttachmentHtmlPreviewFixture() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MulticaColors.Background)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
PilotPageHeader(title = "HTML Attachment Preview")
|
||||||
|
AttachmentHtmlPreview(
|
||||||
|
"""
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
body { font: 16px -apple-system, BlinkMacSystemFont, sans-serif; padding: 16px; color: #111; }
|
||||||
|
button { border: 1px solid #d1d5db; border-radius: 999px; padding: 10px 14px; background: white; }
|
||||||
|
button.active { background: #0A58FF; color: white; }
|
||||||
|
.panel { display: none; margin-top: 16px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 16px; }
|
||||||
|
.panel.active { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>HTML Attachment</h1>
|
||||||
|
<button id="tab-one" class="active" onclick="showTab('one')">Tab One</button>
|
||||||
|
<button id="tab-two" onclick="showTab('two')">Tab Two</button>
|
||||||
|
<a href="#local-anchor" id="anchor-link">Jump to anchor</a>
|
||||||
|
<a href="https://example.com/" id="external-link">External link</a>
|
||||||
|
<section id="one" class="panel active">First tab content</section>
|
||||||
|
<section id="two" class="panel">Second tab content</section>
|
||||||
|
<p id="local-anchor">Local anchor target</p>
|
||||||
|
<script>
|
||||||
|
function showTab(id) {
|
||||||
|
document.querySelectorAll('button').forEach(function(button) { button.classList.remove('active'); });
|
||||||
|
document.querySelectorAll('.panel').forEach(function(panel) { panel.classList.remove('active'); });
|
||||||
|
document.getElementById('tab-' + id).classList.add('active');
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent(),
|
||||||
|
LocalContext.current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun shouldCollapseIssueComment(content: String): Boolean {
|
private fun shouldCollapseIssueComment(content: String): Boolean {
|
||||||
if (content.contains("```")) return false
|
if (content.contains("```")) return false
|
||||||
if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false
|
if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false
|
||||||
@@ -9419,6 +9701,7 @@ private fun PilotIssueDetail(
|
|||||||
var uploadingAttachment by remember(issueId) { mutableStateOf(false) }
|
var uploadingAttachment by remember(issueId) { mutableStateOf(false) }
|
||||||
var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
|
var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
|
||||||
var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||||
|
var previewAttachment by remember(issueId) { mutableStateOf<Models.Attachment?>(null) }
|
||||||
var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) }
|
var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) }
|
||||||
var deletingIssue by remember(issueId) { mutableStateOf(false) }
|
var deletingIssue by remember(issueId) { mutableStateOf(false) }
|
||||||
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||||
@@ -9455,6 +9738,16 @@ private fun PilotIssueDetail(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val activePreviewAttachment = previewAttachment
|
||||||
|
if (activePreviewAttachment != null) {
|
||||||
|
PilotAttachmentPreview(
|
||||||
|
api = api,
|
||||||
|
attachment = activePreviewAttachment,
|
||||||
|
zh = zh,
|
||||||
|
onBack = { previewAttachment = null },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
|
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
|
||||||
state = null
|
state = null
|
||||||
@@ -9701,11 +9994,15 @@ private fun PilotIssueDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openAttachment(attachment: Models.Attachment) {
|
fun openAttachment(attachment: Models.Attachment) {
|
||||||
val target = clean(attachment.downloadUrl).ifBlank { clean(attachment.url) }
|
val target = absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
|
||||||
if (target.isBlank()) {
|
if (target.isBlank()) {
|
||||||
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
|
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (AttachmentPreview.kindFor(attachment.contentType, attachment.filename) != AttachmentPreview.Kind.DOWNLOAD) {
|
||||||
|
previewAttachment = attachment
|
||||||
|
return
|
||||||
|
}
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse(target))
|
Intent(Intent.ACTION_VIEW, Uri.parse(target))
|
||||||
@@ -12052,6 +12349,15 @@ private fun absoluteMarkdownImageUrl(url: String): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun absoluteAttachmentUrl(url: String): String {
|
||||||
|
val value = clean(url)
|
||||||
|
return if (value.startsWith("/")) {
|
||||||
|
BuildConfig.MULTICA_API_BASE_URL.trimEnd('/') + value
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun openExternalUrl(context: Context, url: String) {
|
private fun openExternalUrl(context: Context, url: String) {
|
||||||
val target = clean(url)
|
val target = clean(url)
|
||||||
if (target.isBlank()) return
|
if (target.isBlank()) return
|
||||||
|
|||||||
Reference in New Issue
Block a user