support rich issue comment links
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user