support rich issue comment links

This commit is contained in:
Xisheng-Zhao
2026-05-17 18:32:17 +08:00
parent 271918a8b1
commit 36377f5047
4 changed files with 394 additions and 44 deletions
@@ -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<List<String>>(emptyList())
}
var projectDetail by remember { mutableStateOf<Models.Project?>(null) }
var issueFormOpen by remember { mutableStateOf(false) }
var issueFormIssue by remember { mutableStateOf<Models.Issue?>(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<String> = Models.STATUS_VALUES.filter { it != "cancelled" }
private fun sortIssues(
issues: List<Models.Issue>,
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<Models.Member> = emptyList(),
agents: List<Models.Agent> = 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<String?>(null) }
var issueReferenceMessage by remember(issueId) { mutableStateOf<String?>(null) }
var updatingIssueField by remember(issueId) { mutableStateOf<String?>(null) }
var copyLinkMessage by remember(issueId) { mutableStateOf<String?>(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<Models.Member> = emptyList(),
agents: List<Models.Agent> = 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,
)
},
)
@@ -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<Models.Member> members, List<Models.Agent> 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<String> issueIdentifiersOutsideCodeAndLinks(String value) {
ArrayList<String> 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<Models.Member> members, List<Models.Agent> 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<Models.InboxItem> deduplicateByIssue(Collection<Models.InboxItem> items) {
Map<String, Models.InboxItem> newestByIssue = new LinkedHashMap<>();
@@ -472,6 +472,7 @@ public final class MainActivity extends Activity {
Map<String, List<Models.Issue>> 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));
@@ -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<String> 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<String> paragraph, int textColor) {
private static void flushParagraph(Context context, LinearLayout parent, List<String> 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<String> lines, int textColor, int mutedColor, int borderColor, Palette palette) {
private static void addTable(Context context, LinearLayout parent, List<String> 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);
}