From 832a7e0ea0e8e3657ffa66ad7fb344e55e10d1e7 Mon Sep 17 00:00:00 2001 From: Xisheng-Zhao Date: Sun, 17 May 2026 18:32:17 +0800 Subject: [PATCH] support rich issue comment links --- .../ai/multica/app/ComposePilotActivity.kt | 178 +++++++++++++++--- .../ai/multica/app/FeatureSyncHelpers.java | 110 +++++++++++ .../java/ai/multica/app/MainActivity.java | 1 + .../java/ai/multica/app/MarkdownRenderer.java | 149 +++++++++++++-- 4 files changed, 394 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt index 0548909..2eb6936 100644 --- a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt +++ b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt @@ -1472,6 +1472,9 @@ private fun ComposePilotShell( var issueDetailId by remember(session.workspace.id, initialIssueId) { mutableStateOf(initialIssueId?.takeIf { it.isNotBlank() }) } + var issueDetailBackStack by remember(session.workspace.id, initialIssueId) { + mutableStateOf>(emptyList()) + } var projectDetail by remember { mutableStateOf(null) } var issueFormOpen by remember { mutableStateOf(false) } var issueFormIssue by remember { mutableStateOf(null) } @@ -1499,6 +1502,39 @@ private fun ComposePilotShell( } } + fun clearIssueDetail() { + issueDetailId = null + issueDetailBackStack = emptyList() + inboxIssueContext = null + } + + fun openIssueDetail(issueId: String, preserveCurrent: Boolean = false) { + val currentIssueId = issueDetailId + if (preserveCurrent && !currentIssueId.isNullOrBlank() && currentIssueId != issueId) { + issueDetailBackStack = issueDetailBackStack + currentIssueId + } else if (!preserveCurrent) { + issueDetailBackStack = emptyList() + } + issueDetailId = issueId + inboxIssueContext = null + } + + fun closeIssueDetail() { + if (issueDetailBackStack.isNotEmpty()) { + val previousIssueId = issueDetailBackStack.last() + issueDetailBackStack = issueDetailBackStack.dropLast(1) + issueDetailId = previousIssueId + inboxIssueContext = null + return + } + if (inboxIssueContext != null) tabRefresh++ + clearIssueDetail() + if (returnToSearchAfterDetail) { + searchOpen = true + returnToSearchAfterDetail = false + } + } + LaunchedEffect(selectedTab, session.workspace.id, tabRefresh) { if (issueDetailId != null || projectDetail != null || searchOpen || chatOpen || settingsPage != null || profileOpen) return@LaunchedEffect val currentTabState = tabState @@ -1569,13 +1605,7 @@ private fun ComposePilotShell( chatSession != null -> chatSession = null chatOpen -> chatOpen = false issueDetailId != null -> { - if (inboxIssueContext != null) tabRefresh++ - issueDetailId = null - inboxIssueContext = null - if (returnToSearchAfterDetail) { - searchOpen = true - returnToSearchAfterDetail = false - } + closeIssueDetail() } projectDetail != null -> { projectDetail = null @@ -1600,6 +1630,7 @@ private fun ComposePilotShell( selectedTab = MulticaTab.Settings settingsPage = PilotSettingsPage.WorkspaceDetails issueDetailId = null + issueDetailBackStack = emptyList() inboxIssueContext = null returnToSearchAfterDetail = false projectDetail = null @@ -1615,6 +1646,7 @@ private fun ComposePilotShell( onSearchClick = { searchOpen = true issueDetailId = null + issueDetailBackStack = emptyList() inboxIssueContext = null returnToSearchAfterDetail = false projectDetail = null @@ -1630,6 +1662,7 @@ private fun ComposePilotShell( onChatClick = { selectedTab = MulticaTab.Inbox issueDetailId = null + issueDetailBackStack = emptyList() inboxIssueContext = null returnToSearchAfterDetail = false projectDetail = null @@ -1646,6 +1679,7 @@ private fun ComposePilotShell( onTabClick = { selectedTab = it issueDetailId = null + issueDetailBackStack = emptyList() inboxIssueContext = null returnToSearchAfterDetail = false projectDetail = null @@ -1685,6 +1719,7 @@ private fun ComposePilotShell( projectDetail = null chatOpen = false chatSession = null + issueDetailBackStack = emptyList() issueDetailId = saved.id tabRefresh++ }, @@ -1699,8 +1734,7 @@ private fun ComposePilotShell( onBack = { searchOpen = false }, onIssueClick = { searchOpen = false - issueDetailId = it - inboxIssueContext = null + openIssueDetail(it) returnToSearchAfterDetail = true }, onProjectClick = { @@ -1763,18 +1797,10 @@ private fun ComposePilotShell( currentUser = session.user, zh = zh, onBack = { - if (activeInboxContext != null) tabRefresh++ - issueDetailId = null - inboxIssueContext = null - if (returnToSearchAfterDetail) { - searchOpen = true - returnToSearchAfterDetail = false - } + closeIssueDetail() }, onOpenIssue = { - issueDetailId = it - inboxIssueContext = null - returnToSearchAfterDetail = false + openIssueDetail(it, preserveCurrent = true) }, onEditIssue = { issueFormIssue = it @@ -1789,8 +1815,7 @@ private fun ComposePilotShell( issueFormOpen = true }, onDeleted = { - issueDetailId = null - inboxIssueContext = null + clearIssueDetail() returnToSearchAfterDetail = false tabRefresh++ }, @@ -1798,6 +1823,7 @@ private fun ComposePilotShell( inboxItemId = activeInboxContext?.itemId, inboxNextIssueId = activeInboxContext?.nextIssueId, onInboxDone = { nextIssueId -> + issueDetailBackStack = emptyList() issueDetailId = nextIssueId.takeIf { !it.isNullOrBlank() } inboxIssueContext = null tabRefresh++ @@ -1817,7 +1843,7 @@ private fun ComposePilotShell( } }, onIssueClick = { - issueDetailId = it + openIssueDetail(it) returnToSearchAfterDetail = false }, ) @@ -1831,11 +1857,12 @@ private fun ComposePilotShell( authStore = authStore, selectedIssueIds = selectedIssueIds, onSelectedIssueIdsChange = { selectedIssueIds = it }, - onIssueClick = { issueDetailId = it }, + onIssueClick = { openIssueDetail(it) }, onInboxIssueClick = { item, nextIssueId -> if (item.issueId.isNotBlank()) { markInboxReadOnOpen(item) selectedTab = MulticaTab.Inbox + issueDetailBackStack = emptyList() issueDetailId = item.issueId inboxIssueContext = InboxIssueContext( itemId = item.id, @@ -4503,6 +4530,8 @@ private fun issueStatusIcon(status: String?): ImageVector = when (status) { else -> Icons.Outlined.Archive } +private fun issueBoardStatusValues(): List = Models.STATUS_VALUES.filter { it != "cancelled" } + private fun sortIssues( issues: List, option: IssueSortOption, @@ -5718,10 +5747,11 @@ private fun PilotIssues( fun moveBoardIssue(issue: Models.Issue, direction: Int) { if (movingBoardIssueId != null || direction == 0) return - val currentIndex = Models.STATUS_VALUES.indexOf(issue.status).takeIf { it >= 0 } ?: return - val targetIndex = (currentIndex + direction).coerceIn(0, Models.STATUS_VALUES.lastIndex) + val boardStatuses = issueBoardStatusValues() + val currentIndex = boardStatuses.indexOf(issue.status).takeIf { it >= 0 } ?: return + val targetIndex = (currentIndex + direction).coerceIn(0, boardStatuses.lastIndex) if (targetIndex == currentIndex) return - val targetStatus = Models.STATUS_VALUES[targetIndex] + val targetStatus = boardStatuses[targetIndex] movingBoardIssueId = issue.id batchMessage = null scope.launch { @@ -6425,7 +6455,7 @@ private fun PilotIssueBoard( .padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - items(Models.STATUS_VALUES) { status -> + items(issueBoardStatusValues()) { status -> val bucket = grouped[status].orEmpty() Surface( modifier = Modifier @@ -8764,6 +8794,10 @@ private fun IssueCommentCard( authorName: String, authorAvatarUrl: String = "", authorAgentStatus: String? = null, + members: List = emptyList(), + agents: List = emptyList(), + currentUser: Models.User? = null, + onIssueReferenceClick: ((String) -> Unit)? = null, expandedActions: Boolean, contentExpanded: Boolean, highlighted: Boolean, @@ -8851,7 +8885,13 @@ private fun IssueCommentCard( color = MulticaColors.Muted, ) } else { - MarkdownBlock(renderedContent) + MarkdownBlock( + renderedContent, + members = members, + agents = agents, + currentUser = currentUser, + onIssueReferenceClick = onIssueReferenceClick, + ) if (collapseContent) { MulticaPillButton( text = if (contentExpanded) { @@ -9199,6 +9239,7 @@ private fun PilotIssueDetail( var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) } var deletingIssue by remember(issueId) { mutableStateOf(false) } var issueMutationMessage by remember(issueId) { mutableStateOf(null) } + var issueReferenceMessage by remember(issueId) { mutableStateOf(null) } var updatingIssueField by remember(issueId) { mutableStateOf(null) } var copyLinkMessage by remember(issueId) { mutableStateOf(null) } var issueMoreMenuOpen by remember(issueId) { mutableStateOf(false) } @@ -9348,6 +9389,35 @@ private fun PilotIssueDetail( } } + fun openIssueReference(identifier: String) { + if (identifier.equals(data.issue.identifier, ignoreCase = true)) return + issueReferenceMessage = null + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { api.searchIssues(workspaceId, identifier, 10) } + } + result.onSuccess { issues -> + val match = issues.firstOrNull { it.identifier.equals(identifier, ignoreCase = true) } + ?: issues.firstOrNull() + if (match == null) { + issueReferenceMessage = if (zh) { + "未找到 Issue $identifier" + } else { + "Issue $identifier was not found" + } + } else { + onOpenIssue(match.id) + } + }.onFailure { error -> + issueReferenceMessage = if (zh) { + "打开 Issue $identifier 失败:${error.message ?: error.toString()}" + } else { + "Open Issue $identifier failed: ${error.message ?: error.toString()}" + } + } + } + } + fun runCommentMutation(action: suspend () -> Unit) { if (mutatingComment) return mutatingComment = true @@ -9805,9 +9875,24 @@ private fun PilotIssueDetail( ) } } + if (!issueReferenceMessage.isNullOrBlank()) { + item { + PilotInlineResultState( + message = issueReferenceMessage.orEmpty(), + isError = pilotInlineResultIsError(issueReferenceMessage.orEmpty()), + contentDescription = "Issue Detail Reference Link Result Web Banner", + ) + } + } if (!data.issue.description.isNullOrBlank()) { item { - MarkdownBlock(data.issue.description) + MarkdownBlock( + data.issue.description, + members = data.members, + agents = data.agents, + currentUser = currentUser, + onIssueReferenceClick = { openIssueReference(it) }, + ) } } if (data.activeTasks.isNotEmpty() || data.runs.isNotEmpty()) { @@ -9949,6 +10034,10 @@ private fun PilotIssueDetail( authorName = authorName, authorAvatarUrl = authorAvatarUrl, authorAgentStatus = commentAuthorAgentStatus(comment, data.agents), + members = data.members, + agents = data.agents, + currentUser = currentUser, + onIssueReferenceClick = { openIssueReference(it) }, expandedActions = expandedCommentActionsId == comment.id, contentExpanded = comment.id in expandedCommentContentIds, highlighted = comment.id == highlightCommentId, @@ -11354,10 +11443,38 @@ private fun PilotAgentTranscriptPreview() { } @Composable -private fun MarkdownBlock(markdown: String) { +private fun MarkdownBlock( + markdown: String, + members: List = emptyList(), + agents: List = emptyList(), + currentUser: Models.User? = null, + onIssueReferenceClick: ((String) -> Unit)? = null, +) { val textColor = MulticaColors.Text.toArgb() val mutedColor = MulticaColors.Muted.toArgb() val borderColor = MulticaColors.Border.toArgb() + val linkHandler = remember(members, agents, currentUser, onIssueReferenceClick) { + object : MarkdownRenderer.LinkHandler { + override fun resolveMentionLabel( + mentionType: String, + mentionId: String, + fallbackLabel: String, + ): String { + return IssueCommentRichText.mentionDisplayLabel( + mentionType, + mentionId, + fallbackLabel, + members, + agents, + currentUser, + ) + } + + override fun openIssueIdentifier(identifier: String) { + if (identifier.isNotBlank()) onIssueReferenceClick?.invoke(identifier) + } + } + } SelectionContainer( modifier = Modifier .fillMaxWidth() @@ -11377,6 +11494,7 @@ private fun MarkdownBlock(markdown: String) { textColor, mutedColor, borderColor, + linkHandler, ) }, ) diff --git a/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java b/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java index 1cf489a..2bbab06 100644 --- a/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java +++ b/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java @@ -8,6 +8,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; final class AgentMentionMarkdown { static String markdown(String name, String agentId) { @@ -22,6 +24,114 @@ final class AgentMentionMarkdown { } } +final class IssueCommentRichText { + private static final Pattern ISSUE_IDENTIFIER = Pattern.compile("[A-Z][A-Z0-9]+-\\d+"); + + static String mentionDisplayLabel(String mentionType, String mentionId, String fallbackLabel, + List members, List agents, + Models.User currentUser) { + String resolved = resolveMentionName(mentionType, mentionId, members, agents, currentUser); + String label = resolved.isEmpty() ? cleanMentionFallback(fallbackLabel, mentionId) : resolved; + if (label.startsWith("@")) return label; + return "@" + label; + } + + static int issueIdentifierEndAt(String value, int start) { + if (value == null || start < 0 || start >= value.length()) return -1; + Matcher matcher = ISSUE_IDENTIFIER.matcher(value); + matcher.region(start, value.length()); + if (!matcher.lookingAt()) return -1; + int end = matcher.end(); + if (!hasIdentifierBoundary(value, start, end)) return -1; + return end; + } + + static List issueIdentifiersOutsideCodeAndLinks(String value) { + ArrayList identifiers = new ArrayList<>(); + if (value == null || value.isEmpty()) return identifiers; + int i = 0; + while (i < value.length()) { + if (value.charAt(i) == '`') { + int end = value.indexOf('`', i + 1); + i = end > i ? end + 1 : i + 1; + continue; + } + int markdownLinkEnd = markdownLinkEndAt(value, i); + if (markdownLinkEnd > i) { + i = markdownLinkEnd; + continue; + } + int end = issueIdentifierEndAt(value, i); + if (end > i) { + identifiers.add(value.substring(i, end)); + i = end; + } else { + i++; + } + } + return identifiers; + } + + private static String resolveMentionName(String mentionType, String mentionId, + List members, List agents, + Models.User currentUser) { + String type = mentionType == null ? "" : mentionType.toLowerCase(Locale.US); + String id = mentionId == null ? "" : mentionId; + if ("agent".equals(type)) { + for (Models.Agent agent : agents) { + if (id.equals(agent.id)) return clean(agent.name); + } + } + if ("member".equals(type) || "user".equals(type)) { + if (currentUser != null && id.equals(currentUser.id)) return clean(currentUser.name); + for (Models.Member member : members) { + if (id.equals(member.id) || id.equals(member.userId)) return clean(member.displayName); + } + } + for (Models.Agent agent : agents) { + if (id.equals(agent.id)) return clean(agent.name); + } + if (currentUser != null && id.equals(currentUser.id)) return clean(currentUser.name); + for (Models.Member member : members) { + if (id.equals(member.id) || id.equals(member.userId)) return clean(member.displayName); + } + return ""; + } + + private static String cleanMentionFallback(String fallbackLabel, String mentionId) { + String fallback = clean(fallbackLabel); + while (fallback.startsWith("@")) fallback = fallback.substring(1).trim(); + return fallback.isEmpty() ? Models.shortId(mentionId) : fallback; + } + + private static boolean hasIdentifierBoundary(String value, int start, int end) { + boolean left = start == 0 || !isIssueIdentifierNeighbor(value.charAt(start - 1)); + boolean right = end >= value.length() || !isIssueIdentifierNeighbor(value.charAt(end)); + return left && right; + } + + private static boolean isIssueIdentifierNeighbor(char ch) { + return Character.isLetterOrDigit(ch) || ch == '_' || ch == '-'; + } + + private static int markdownLinkEndAt(String value, int start) { + if (start < 0 || start >= value.length() || value.charAt(start) != '[') return -1; + int labelEnd = value.indexOf("](", start + 1); + if (labelEnd <= start + 1) return -1; + int urlEnd = value.indexOf(')', labelEnd + 2); + return urlEnd > labelEnd ? urlEnd + 1 : -1; + } + + private static String clean(String value) { + if (value == null) return ""; + String trimmed = value.trim(); + return "null".equalsIgnoreCase(trimmed) ? "" : trimmed; + } + + private IssueCommentRichText() { + } +} + final class InboxNotificationDeduper { static List deduplicateByIssue(Collection items) { Map newestByIssue = new LinkedHashMap<>(); diff --git a/app/src/main/java/ai/multica/app/MainActivity.java b/app/src/main/java/ai/multica/app/MainActivity.java index eda8b88..1b7222e 100644 --- a/app/src/main/java/ai/multica/app/MainActivity.java +++ b/app/src/main/java/ai/multica/app/MainActivity.java @@ -472,6 +472,7 @@ public final class MainActivity extends Activity { Map> grouped = new HashMap<>(); for (Models.Issue issue : issues) grouped.computeIfAbsent(issue.status, k -> new ArrayList<>()).add(issue); for (String status : Models.STATUS_VALUES) { + if ("cancelled".equals(status)) continue; LinearLayout col = vertical(); col.setPadding(dp(8), dp(8), dp(8), dp(8)); col.setBackground(roundedStroke(0xFFF3F4F6, BORDER, 14, 1)); diff --git a/app/src/main/java/ai/multica/app/MarkdownRenderer.java b/app/src/main/java/ai/multica/app/MarkdownRenderer.java index 1953a08..aa79897 100644 --- a/app/src/main/java/ai/multica/app/MarkdownRenderer.java +++ b/app/src/main/java/ai/multica/app/MarkdownRenderer.java @@ -6,8 +6,12 @@ import android.graphics.Typeface; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.method.LinkMovementMethod; +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import android.text.style.TypefaceSpan; +import android.text.style.UnderlineSpan; import android.view.ViewGroup; import android.view.View; import android.widget.HorizontalScrollView; @@ -22,6 +26,13 @@ import java.util.List; final class MarkdownRenderer { private MarkdownRenderer() {} + private static final int LINK_BLUE = 0xFF007AFF; + + interface LinkHandler { + String resolveMentionLabel(String mentionType, String mentionId, String fallbackLabel); + void openIssueIdentifier(String identifier); + } + static final class Palette { final int blockBackground; final int tableHeaderBackground; @@ -43,6 +54,11 @@ final class MarkdownRenderer { } static void render(Context context, LinearLayout parent, String markdown, int textColor, int mutedColor, int borderColor) { + render(context, parent, markdown, textColor, mutedColor, borderColor, null); + } + + static void render(Context context, LinearLayout parent, String markdown, int textColor, int mutedColor, int borderColor, + LinkHandler linkHandler) { parent.removeAllViews(); Palette palette = paletteFor(textColor, mutedColor, borderColor); if (markdown == null || markdown.trim().isEmpty()) { @@ -59,7 +75,7 @@ final class MarkdownRenderer { for (int i = 0; i < lines.length; i++) { String line = lines[i]; if (line.trim().startsWith("```")) { - flushParagraph(context, parent, paragraph, textColor); + flushParagraph(context, parent, paragraph, textColor, linkHandler); if (inCode) { addCodeBlock(context, parent, code.toString(), mutedColor, palette); code.setLength(0); @@ -75,7 +91,7 @@ final class MarkdownRenderer { } if (isTableStart(lines, i)) { - flushParagraph(context, parent, paragraph, textColor); + flushParagraph(context, parent, paragraph, textColor, linkHandler); List table = new ArrayList<>(); table.add(lines[i]); i += 2; @@ -84,35 +100,37 @@ final class MarkdownRenderer { i++; } i--; - addTable(context, parent, table, textColor, mutedColor, borderColor, palette); + addTable(context, parent, table, textColor, mutedColor, borderColor, palette, linkHandler); continue; } String trimmed = line.trim(); if (trimmed.isEmpty()) { - flushParagraph(context, parent, paragraph, textColor); + flushParagraph(context, parent, paragraph, textColor, linkHandler); } else if (trimmed.matches("^(-{3,}|\\*{3,}|_{3,})$")) { - flushParagraph(context, parent, paragraph, textColor); + flushParagraph(context, parent, paragraph, textColor, linkHandler); addDivider(context, parent, borderColor); } else if (trimmed.startsWith("#")) { - flushParagraph(context, parent, paragraph, textColor); + flushParagraph(context, parent, paragraph, textColor, linkHandler); addHeading(context, parent, trimmed, textColor); } else if (trimmed.startsWith(">")) { - flushParagraph(context, parent, paragraph, textColor); + flushParagraph(context, parent, paragraph, textColor, linkHandler); TextView quote = text(context, trimmed.replaceFirst("^>\\s?", ""), 15, mutedColor); quote.setPadding(dp(context, 10), dp(context, 6), dp(context, 8), dp(context, 6)); quote.setBackgroundColor(palette.blockBackground); parent.addView(quote); } else if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { - flushParagraph(context, parent, paragraph, textColor); + flushParagraph(context, parent, paragraph, textColor, linkHandler); TextView bullet = text(context, "• " + trimmed.substring(2), 15, textColor); + bullet.setText(applyInline(bullet.getText().toString(), linkHandler)); + bullet.setMovementMethod(LinkMovementMethod.getInstance()); bullet.setPadding(dp(context, 8), dp(context, 2), 0, dp(context, 2)); parent.addView(bullet); } else { paragraph.add(line); } } - flushParagraph(context, parent, paragraph, textColor); + flushParagraph(context, parent, paragraph, textColor, linkHandler); if (code.length() > 0) addCodeBlock(context, parent, code.toString(), mutedColor, palette); } @@ -124,10 +142,11 @@ final class MarkdownRenderer { && divider.matches("^\\|?\\s*:?-{3,}:?\\s*(\\|\\s*:?-{3,}:?\\s*)+\\|?$"); } - private static void flushParagraph(Context context, LinearLayout parent, List paragraph, int textColor) { + private static void flushParagraph(Context context, LinearLayout parent, List paragraph, int textColor, + LinkHandler linkHandler) { if (paragraph.isEmpty()) return; TextView p = text(context, String.join("\n", paragraph), 15, textColor); - p.setText(applyInline(p.getText().toString())); + p.setText(applyInline(p.getText().toString(), linkHandler)); p.setMovementMethod(LinkMovementMethod.getInstance()); p.setPadding(0, dp(context, 2), 0, dp(context, 6)); parent.addView(p); @@ -160,7 +179,8 @@ final class MarkdownRenderer { parent.addView(line, params); } - private static void addTable(Context context, LinearLayout parent, List lines, int textColor, int mutedColor, int borderColor, Palette palette) { + private static void addTable(Context context, LinearLayout parent, List lines, int textColor, int mutedColor, int borderColor, + Palette palette, LinkHandler linkHandler) { HorizontalScrollView scroll = new HorizontalScrollView(context); TableLayout table = new TableLayout(context); table.setShrinkAllColumns(false); @@ -172,7 +192,8 @@ final class MarkdownRenderer { for (String cell : cells) { String cellText = cell.trim(); TextView tv = text(context, "", 14, r == 0 ? textColor : mutedColor); - tv.setText(applyInline(cellText)); + tv.setText(applyInline(cellText, linkHandler)); + tv.setMovementMethod(LinkMovementMethod.getInstance()); tv.setTypeface(r == 0 ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT); tv.setPadding(dp(context, 10), dp(context, 8), dp(context, 10), dp(context, 8)); tv.setBackgroundColor(r == 0 ? palette.tableHeaderBackground : palette.tableCellBackground); @@ -253,7 +274,11 @@ final class MarkdownRenderer { return tv; } - private static SpannableStringBuilder applyInline(String value) { + static SpannableStringBuilder applyInline(String value) { + return applyInline(value, null); + } + + private static SpannableStringBuilder applyInline(String value, LinkHandler linkHandler) { SpannableStringBuilder out = new SpannableStringBuilder(); int i = 0; while (i < value.length()) { @@ -277,12 +302,108 @@ final class MarkdownRenderer { continue; } } + if (value.charAt(i) == '[') { + ParsedMarkdownLink link = parseMarkdownLink(value, i); + if (link != null) { + appendMarkdownLink(out, link, linkHandler); + i = link.end; + continue; + } + } + int issueIdentifierEnd = IssueCommentRichText.issueIdentifierEndAt(value, i); + if (issueIdentifierEnd > i) { + appendIssueIdentifier(out, value.substring(i, issueIdentifierEnd), linkHandler); + i = issueIdentifierEnd; + continue; + } out.append(value.charAt(i)); i++; } return out; } + private static void appendMarkdownLink(SpannableStringBuilder out, ParsedMarkdownLink link, LinkHandler linkHandler) { + MentionUri mention = MentionUri.parse(link.url); + if (mention != null) { + String label = linkHandler == null + ? IssueCommentRichText.mentionDisplayLabel(mention.type, mention.id, link.label, new ArrayList<>(), new ArrayList<>(), null) + : linkHandler.resolveMentionLabel(mention.type, mention.id, link.label); + appendLinkedText(out, label == null ? link.label : label, null); + return; + } + appendLinkedText(out, link.label, null); + } + + private static void appendIssueIdentifier(SpannableStringBuilder out, String identifier, LinkHandler linkHandler) { + appendLinkedText(out, identifier, view -> { + if (linkHandler != null) linkHandler.openIssueIdentifier(identifier); + }); + } + + private static void appendLinkedText(SpannableStringBuilder out, String text, View.OnClickListener onClick) { + int start = out.length(); + out.append(text == null ? "" : text); + int end = out.length(); + if (end <= start) return; + out.setSpan(new ForegroundColorSpan(LINK_BLUE), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + out.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (onClick != null) { + out.setSpan(new ClickableSpan() { + @Override + public void onClick(View widget) { + onClick.onClick(widget); + } + + @Override + public void updateDrawState(TextPaint ds) { + super.updateDrawState(ds); + ds.setColor(LINK_BLUE); + ds.setUnderlineText(true); + } + }, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + private static ParsedMarkdownLink parseMarkdownLink(String value, int start) { + int labelEnd = value.indexOf("](", start + 1); + if (labelEnd <= start + 1) return null; + int urlEnd = value.indexOf(')', labelEnd + 2); + if (urlEnd <= labelEnd + 2) return null; + String label = value.substring(start + 1, labelEnd); + String url = value.substring(labelEnd + 2, urlEnd); + return new ParsedMarkdownLink(label, url, urlEnd + 1); + } + + private static final class ParsedMarkdownLink { + final String label; + final String url; + final int end; + + ParsedMarkdownLink(String label, String url, int end) { + this.label = label; + this.url = url; + this.end = end; + } + } + + private static final class MentionUri { + final String type; + final String id; + + private MentionUri(String type, String id) { + this.type = type; + this.id = id; + } + + static MentionUri parse(String url) { + if (url == null || !url.startsWith("mention://")) return null; + String path = url.substring("mention://".length()); + int slash = path.indexOf('/'); + if (slash <= 0 || slash >= path.length() - 1) return null; + return new MentionUri(path.substring(0, slash), path.substring(slash + 1)); + } + } + static int dp(Context context, int value) { return Math.round(value * context.getResources().getDisplayMetrics().density); }