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) {
|
var issueDetailId by remember(session.workspace.id, initialIssueId) {
|
||||||
mutableStateOf(initialIssueId?.takeIf { it.isNotBlank() })
|
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 projectDetail by remember { mutableStateOf<Models.Project?>(null) }
|
||||||
var issueFormOpen by remember { mutableStateOf(false) }
|
var issueFormOpen by remember { mutableStateOf(false) }
|
||||||
var issueFormIssue by remember { mutableStateOf<Models.Issue?>(null) }
|
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) {
|
LaunchedEffect(selectedTab, session.workspace.id, tabRefresh) {
|
||||||
if (issueDetailId != null || projectDetail != null || searchOpen || chatOpen || settingsPage != null || profileOpen) return@LaunchedEffect
|
if (issueDetailId != null || projectDetail != null || searchOpen || chatOpen || settingsPage != null || profileOpen) return@LaunchedEffect
|
||||||
val currentTabState = tabState
|
val currentTabState = tabState
|
||||||
@@ -1569,13 +1605,7 @@ private fun ComposePilotShell(
|
|||||||
chatSession != null -> chatSession = null
|
chatSession != null -> chatSession = null
|
||||||
chatOpen -> chatOpen = false
|
chatOpen -> chatOpen = false
|
||||||
issueDetailId != null -> {
|
issueDetailId != null -> {
|
||||||
if (inboxIssueContext != null) tabRefresh++
|
closeIssueDetail()
|
||||||
issueDetailId = null
|
|
||||||
inboxIssueContext = null
|
|
||||||
if (returnToSearchAfterDetail) {
|
|
||||||
searchOpen = true
|
|
||||||
returnToSearchAfterDetail = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
projectDetail != null -> {
|
projectDetail != null -> {
|
||||||
projectDetail = null
|
projectDetail = null
|
||||||
@@ -1600,6 +1630,7 @@ private fun ComposePilotShell(
|
|||||||
selectedTab = MulticaTab.Settings
|
selectedTab = MulticaTab.Settings
|
||||||
settingsPage = PilotSettingsPage.WorkspaceDetails
|
settingsPage = PilotSettingsPage.WorkspaceDetails
|
||||||
issueDetailId = null
|
issueDetailId = null
|
||||||
|
issueDetailBackStack = emptyList()
|
||||||
inboxIssueContext = null
|
inboxIssueContext = null
|
||||||
returnToSearchAfterDetail = false
|
returnToSearchAfterDetail = false
|
||||||
projectDetail = null
|
projectDetail = null
|
||||||
@@ -1615,6 +1646,7 @@ private fun ComposePilotShell(
|
|||||||
onSearchClick = {
|
onSearchClick = {
|
||||||
searchOpen = true
|
searchOpen = true
|
||||||
issueDetailId = null
|
issueDetailId = null
|
||||||
|
issueDetailBackStack = emptyList()
|
||||||
inboxIssueContext = null
|
inboxIssueContext = null
|
||||||
returnToSearchAfterDetail = false
|
returnToSearchAfterDetail = false
|
||||||
projectDetail = null
|
projectDetail = null
|
||||||
@@ -1630,6 +1662,7 @@ private fun ComposePilotShell(
|
|||||||
onChatClick = {
|
onChatClick = {
|
||||||
selectedTab = MulticaTab.Inbox
|
selectedTab = MulticaTab.Inbox
|
||||||
issueDetailId = null
|
issueDetailId = null
|
||||||
|
issueDetailBackStack = emptyList()
|
||||||
inboxIssueContext = null
|
inboxIssueContext = null
|
||||||
returnToSearchAfterDetail = false
|
returnToSearchAfterDetail = false
|
||||||
projectDetail = null
|
projectDetail = null
|
||||||
@@ -1646,6 +1679,7 @@ private fun ComposePilotShell(
|
|||||||
onTabClick = {
|
onTabClick = {
|
||||||
selectedTab = it
|
selectedTab = it
|
||||||
issueDetailId = null
|
issueDetailId = null
|
||||||
|
issueDetailBackStack = emptyList()
|
||||||
inboxIssueContext = null
|
inboxIssueContext = null
|
||||||
returnToSearchAfterDetail = false
|
returnToSearchAfterDetail = false
|
||||||
projectDetail = null
|
projectDetail = null
|
||||||
@@ -1685,6 +1719,7 @@ private fun ComposePilotShell(
|
|||||||
projectDetail = null
|
projectDetail = null
|
||||||
chatOpen = false
|
chatOpen = false
|
||||||
chatSession = null
|
chatSession = null
|
||||||
|
issueDetailBackStack = emptyList()
|
||||||
issueDetailId = saved.id
|
issueDetailId = saved.id
|
||||||
tabRefresh++
|
tabRefresh++
|
||||||
},
|
},
|
||||||
@@ -1699,8 +1734,7 @@ private fun ComposePilotShell(
|
|||||||
onBack = { searchOpen = false },
|
onBack = { searchOpen = false },
|
||||||
onIssueClick = {
|
onIssueClick = {
|
||||||
searchOpen = false
|
searchOpen = false
|
||||||
issueDetailId = it
|
openIssueDetail(it)
|
||||||
inboxIssueContext = null
|
|
||||||
returnToSearchAfterDetail = true
|
returnToSearchAfterDetail = true
|
||||||
},
|
},
|
||||||
onProjectClick = {
|
onProjectClick = {
|
||||||
@@ -1763,18 +1797,10 @@ private fun ComposePilotShell(
|
|||||||
currentUser = session.user,
|
currentUser = session.user,
|
||||||
zh = zh,
|
zh = zh,
|
||||||
onBack = {
|
onBack = {
|
||||||
if (activeInboxContext != null) tabRefresh++
|
closeIssueDetail()
|
||||||
issueDetailId = null
|
|
||||||
inboxIssueContext = null
|
|
||||||
if (returnToSearchAfterDetail) {
|
|
||||||
searchOpen = true
|
|
||||||
returnToSearchAfterDetail = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onOpenIssue = {
|
onOpenIssue = {
|
||||||
issueDetailId = it
|
openIssueDetail(it, preserveCurrent = true)
|
||||||
inboxIssueContext = null
|
|
||||||
returnToSearchAfterDetail = false
|
|
||||||
},
|
},
|
||||||
onEditIssue = {
|
onEditIssue = {
|
||||||
issueFormIssue = it
|
issueFormIssue = it
|
||||||
@@ -1789,8 +1815,7 @@ private fun ComposePilotShell(
|
|||||||
issueFormOpen = true
|
issueFormOpen = true
|
||||||
},
|
},
|
||||||
onDeleted = {
|
onDeleted = {
|
||||||
issueDetailId = null
|
clearIssueDetail()
|
||||||
inboxIssueContext = null
|
|
||||||
returnToSearchAfterDetail = false
|
returnToSearchAfterDetail = false
|
||||||
tabRefresh++
|
tabRefresh++
|
||||||
},
|
},
|
||||||
@@ -1798,6 +1823,7 @@ private fun ComposePilotShell(
|
|||||||
inboxItemId = activeInboxContext?.itemId,
|
inboxItemId = activeInboxContext?.itemId,
|
||||||
inboxNextIssueId = activeInboxContext?.nextIssueId,
|
inboxNextIssueId = activeInboxContext?.nextIssueId,
|
||||||
onInboxDone = { nextIssueId ->
|
onInboxDone = { nextIssueId ->
|
||||||
|
issueDetailBackStack = emptyList()
|
||||||
issueDetailId = nextIssueId.takeIf { !it.isNullOrBlank() }
|
issueDetailId = nextIssueId.takeIf { !it.isNullOrBlank() }
|
||||||
inboxIssueContext = null
|
inboxIssueContext = null
|
||||||
tabRefresh++
|
tabRefresh++
|
||||||
@@ -1817,7 +1843,7 @@ private fun ComposePilotShell(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onIssueClick = {
|
onIssueClick = {
|
||||||
issueDetailId = it
|
openIssueDetail(it)
|
||||||
returnToSearchAfterDetail = false
|
returnToSearchAfterDetail = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1831,11 +1857,12 @@ private fun ComposePilotShell(
|
|||||||
authStore = authStore,
|
authStore = authStore,
|
||||||
selectedIssueIds = selectedIssueIds,
|
selectedIssueIds = selectedIssueIds,
|
||||||
onSelectedIssueIdsChange = { selectedIssueIds = it },
|
onSelectedIssueIdsChange = { selectedIssueIds = it },
|
||||||
onIssueClick = { issueDetailId = it },
|
onIssueClick = { openIssueDetail(it) },
|
||||||
onInboxIssueClick = { item, nextIssueId ->
|
onInboxIssueClick = { item, nextIssueId ->
|
||||||
if (item.issueId.isNotBlank()) {
|
if (item.issueId.isNotBlank()) {
|
||||||
markInboxReadOnOpen(item)
|
markInboxReadOnOpen(item)
|
||||||
selectedTab = MulticaTab.Inbox
|
selectedTab = MulticaTab.Inbox
|
||||||
|
issueDetailBackStack = emptyList()
|
||||||
issueDetailId = item.issueId
|
issueDetailId = item.issueId
|
||||||
inboxIssueContext = InboxIssueContext(
|
inboxIssueContext = InboxIssueContext(
|
||||||
itemId = item.id,
|
itemId = item.id,
|
||||||
@@ -4503,6 +4530,8 @@ private fun issueStatusIcon(status: String?): ImageVector = when (status) {
|
|||||||
else -> Icons.Outlined.Archive
|
else -> Icons.Outlined.Archive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun issueBoardStatusValues(): List<String> = Models.STATUS_VALUES.filter { it != "cancelled" }
|
||||||
|
|
||||||
private fun sortIssues(
|
private fun sortIssues(
|
||||||
issues: List<Models.Issue>,
|
issues: List<Models.Issue>,
|
||||||
option: IssueSortOption,
|
option: IssueSortOption,
|
||||||
@@ -5718,10 +5747,11 @@ private fun PilotIssues(
|
|||||||
|
|
||||||
fun moveBoardIssue(issue: Models.Issue, direction: Int) {
|
fun moveBoardIssue(issue: Models.Issue, direction: Int) {
|
||||||
if (movingBoardIssueId != null || direction == 0) return
|
if (movingBoardIssueId != null || direction == 0) return
|
||||||
val currentIndex = Models.STATUS_VALUES.indexOf(issue.status).takeIf { it >= 0 } ?: return
|
val boardStatuses = issueBoardStatusValues()
|
||||||
val targetIndex = (currentIndex + direction).coerceIn(0, Models.STATUS_VALUES.lastIndex)
|
val currentIndex = boardStatuses.indexOf(issue.status).takeIf { it >= 0 } ?: return
|
||||||
|
val targetIndex = (currentIndex + direction).coerceIn(0, boardStatuses.lastIndex)
|
||||||
if (targetIndex == currentIndex) return
|
if (targetIndex == currentIndex) return
|
||||||
val targetStatus = Models.STATUS_VALUES[targetIndex]
|
val targetStatus = boardStatuses[targetIndex]
|
||||||
movingBoardIssueId = issue.id
|
movingBoardIssueId = issue.id
|
||||||
batchMessage = null
|
batchMessage = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -6425,7 +6455,7 @@ private fun PilotIssueBoard(
|
|||||||
.padding(top = 8.dp),
|
.padding(top = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
items(Models.STATUS_VALUES) { status ->
|
items(issueBoardStatusValues()) { status ->
|
||||||
val bucket = grouped[status].orEmpty()
|
val bucket = grouped[status].orEmpty()
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -8764,6 +8794,10 @@ private fun IssueCommentCard(
|
|||||||
authorName: String,
|
authorName: String,
|
||||||
authorAvatarUrl: String = "",
|
authorAvatarUrl: String = "",
|
||||||
authorAgentStatus: String? = null,
|
authorAgentStatus: String? = null,
|
||||||
|
members: List<Models.Member> = emptyList(),
|
||||||
|
agents: List<Models.Agent> = emptyList(),
|
||||||
|
currentUser: Models.User? = null,
|
||||||
|
onIssueReferenceClick: ((String) -> Unit)? = null,
|
||||||
expandedActions: Boolean,
|
expandedActions: Boolean,
|
||||||
contentExpanded: Boolean,
|
contentExpanded: Boolean,
|
||||||
highlighted: Boolean,
|
highlighted: Boolean,
|
||||||
@@ -8851,7 +8885,13 @@ private fun IssueCommentCard(
|
|||||||
color = MulticaColors.Muted,
|
color = MulticaColors.Muted,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MarkdownBlock(renderedContent)
|
MarkdownBlock(
|
||||||
|
renderedContent,
|
||||||
|
members = members,
|
||||||
|
agents = agents,
|
||||||
|
currentUser = currentUser,
|
||||||
|
onIssueReferenceClick = onIssueReferenceClick,
|
||||||
|
)
|
||||||
if (collapseContent) {
|
if (collapseContent) {
|
||||||
MulticaPillButton(
|
MulticaPillButton(
|
||||||
text = if (contentExpanded) {
|
text = if (contentExpanded) {
|
||||||
@@ -9199,6 +9239,7 @@ private fun PilotIssueDetail(
|
|||||||
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) }
|
||||||
|
var issueReferenceMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||||
var updatingIssueField by remember(issueId) { mutableStateOf<String?>(null) }
|
var updatingIssueField by remember(issueId) { mutableStateOf<String?>(null) }
|
||||||
var copyLinkMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
var copyLinkMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||||
var issueMoreMenuOpen by remember(issueId) { mutableStateOf(false) }
|
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) {
|
fun runCommentMutation(action: suspend () -> Unit) {
|
||||||
if (mutatingComment) return
|
if (mutatingComment) return
|
||||||
mutatingComment = true
|
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()) {
|
if (!data.issue.description.isNullOrBlank()) {
|
||||||
item {
|
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()) {
|
if (data.activeTasks.isNotEmpty() || data.runs.isNotEmpty()) {
|
||||||
@@ -9949,6 +10034,10 @@ private fun PilotIssueDetail(
|
|||||||
authorName = authorName,
|
authorName = authorName,
|
||||||
authorAvatarUrl = authorAvatarUrl,
|
authorAvatarUrl = authorAvatarUrl,
|
||||||
authorAgentStatus = commentAuthorAgentStatus(comment, data.agents),
|
authorAgentStatus = commentAuthorAgentStatus(comment, data.agents),
|
||||||
|
members = data.members,
|
||||||
|
agents = data.agents,
|
||||||
|
currentUser = currentUser,
|
||||||
|
onIssueReferenceClick = { openIssueReference(it) },
|
||||||
expandedActions = expandedCommentActionsId == comment.id,
|
expandedActions = expandedCommentActionsId == comment.id,
|
||||||
contentExpanded = comment.id in expandedCommentContentIds,
|
contentExpanded = comment.id in expandedCommentContentIds,
|
||||||
highlighted = comment.id == highlightCommentId,
|
highlighted = comment.id == highlightCommentId,
|
||||||
@@ -11354,10 +11443,38 @@ private fun PilotAgentTranscriptPreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 textColor = MulticaColors.Text.toArgb()
|
||||||
val mutedColor = MulticaColors.Muted.toArgb()
|
val mutedColor = MulticaColors.Muted.toArgb()
|
||||||
val borderColor = MulticaColors.Border.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(
|
SelectionContainer(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -11377,6 +11494,7 @@ private fun MarkdownBlock(markdown: String) {
|
|||||||
textColor,
|
textColor,
|
||||||
mutedColor,
|
mutedColor,
|
||||||
borderColor,
|
borderColor,
|
||||||
|
linkHandler,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
final class AgentMentionMarkdown {
|
final class AgentMentionMarkdown {
|
||||||
static String markdown(String name, String agentId) {
|
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 {
|
final class InboxNotificationDeduper {
|
||||||
static List<Models.InboxItem> deduplicateByIssue(Collection<Models.InboxItem> items) {
|
static List<Models.InboxItem> deduplicateByIssue(Collection<Models.InboxItem> items) {
|
||||||
Map<String, Models.InboxItem> newestByIssue = new LinkedHashMap<>();
|
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<>();
|
Map<String, List<Models.Issue>> grouped = new HashMap<>();
|
||||||
for (Models.Issue issue : issues) grouped.computeIfAbsent(issue.status, k -> new ArrayList<>()).add(issue);
|
for (Models.Issue issue : issues) grouped.computeIfAbsent(issue.status, k -> new ArrayList<>()).add(issue);
|
||||||
for (String status : Models.STATUS_VALUES) {
|
for (String status : Models.STATUS_VALUES) {
|
||||||
|
if ("cancelled".equals(status)) continue;
|
||||||
LinearLayout col = vertical();
|
LinearLayout col = vertical();
|
||||||
col.setPadding(dp(8), dp(8), dp(8), dp(8));
|
col.setPadding(dp(8), dp(8), dp(8), dp(8));
|
||||||
col.setBackground(roundedStroke(0xFFF3F4F6, BORDER, 14, 1));
|
col.setBackground(roundedStroke(0xFFF3F4F6, BORDER, 14, 1));
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import android.graphics.Typeface;
|
|||||||
import android.text.SpannableStringBuilder;
|
import android.text.SpannableStringBuilder;
|
||||||
import android.text.Spanned;
|
import android.text.Spanned;
|
||||||
import android.text.method.LinkMovementMethod;
|
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.StyleSpan;
|
||||||
import android.text.style.TypefaceSpan;
|
import android.text.style.TypefaceSpan;
|
||||||
|
import android.text.style.UnderlineSpan;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.HorizontalScrollView;
|
import android.widget.HorizontalScrollView;
|
||||||
@@ -22,6 +26,13 @@ import java.util.List;
|
|||||||
final class MarkdownRenderer {
|
final class MarkdownRenderer {
|
||||||
private 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 {
|
static final class Palette {
|
||||||
final int blockBackground;
|
final int blockBackground;
|
||||||
final int tableHeaderBackground;
|
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) {
|
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();
|
parent.removeAllViews();
|
||||||
Palette palette = paletteFor(textColor, mutedColor, borderColor);
|
Palette palette = paletteFor(textColor, mutedColor, borderColor);
|
||||||
if (markdown == null || markdown.trim().isEmpty()) {
|
if (markdown == null || markdown.trim().isEmpty()) {
|
||||||
@@ -59,7 +75,7 @@ final class MarkdownRenderer {
|
|||||||
for (int i = 0; i < lines.length; i++) {
|
for (int i = 0; i < lines.length; i++) {
|
||||||
String line = lines[i];
|
String line = lines[i];
|
||||||
if (line.trim().startsWith("```")) {
|
if (line.trim().startsWith("```")) {
|
||||||
flushParagraph(context, parent, paragraph, textColor);
|
flushParagraph(context, parent, paragraph, textColor, linkHandler);
|
||||||
if (inCode) {
|
if (inCode) {
|
||||||
addCodeBlock(context, parent, code.toString(), mutedColor, palette);
|
addCodeBlock(context, parent, code.toString(), mutedColor, palette);
|
||||||
code.setLength(0);
|
code.setLength(0);
|
||||||
@@ -75,7 +91,7 @@ final class MarkdownRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isTableStart(lines, i)) {
|
if (isTableStart(lines, i)) {
|
||||||
flushParagraph(context, parent, paragraph, textColor);
|
flushParagraph(context, parent, paragraph, textColor, linkHandler);
|
||||||
List<String> table = new ArrayList<>();
|
List<String> table = new ArrayList<>();
|
||||||
table.add(lines[i]);
|
table.add(lines[i]);
|
||||||
i += 2;
|
i += 2;
|
||||||
@@ -84,35 +100,37 @@ final class MarkdownRenderer {
|
|||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
i--;
|
i--;
|
||||||
addTable(context, parent, table, textColor, mutedColor, borderColor, palette);
|
addTable(context, parent, table, textColor, mutedColor, borderColor, palette, linkHandler);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
String trimmed = line.trim();
|
String trimmed = line.trim();
|
||||||
if (trimmed.isEmpty()) {
|
if (trimmed.isEmpty()) {
|
||||||
flushParagraph(context, parent, paragraph, textColor);
|
flushParagraph(context, parent, paragraph, textColor, linkHandler);
|
||||||
} else if (trimmed.matches("^(-{3,}|\\*{3,}|_{3,})$")) {
|
} else if (trimmed.matches("^(-{3,}|\\*{3,}|_{3,})$")) {
|
||||||
flushParagraph(context, parent, paragraph, textColor);
|
flushParagraph(context, parent, paragraph, textColor, linkHandler);
|
||||||
addDivider(context, parent, borderColor);
|
addDivider(context, parent, borderColor);
|
||||||
} else if (trimmed.startsWith("#")) {
|
} else if (trimmed.startsWith("#")) {
|
||||||
flushParagraph(context, parent, paragraph, textColor);
|
flushParagraph(context, parent, paragraph, textColor, linkHandler);
|
||||||
addHeading(context, parent, trimmed, textColor);
|
addHeading(context, parent, trimmed, textColor);
|
||||||
} else if (trimmed.startsWith(">")) {
|
} else if (trimmed.startsWith(">")) {
|
||||||
flushParagraph(context, parent, paragraph, textColor);
|
flushParagraph(context, parent, paragraph, textColor, linkHandler);
|
||||||
TextView quote = text(context, trimmed.replaceFirst("^>\\s?", ""), 15, mutedColor);
|
TextView quote = text(context, trimmed.replaceFirst("^>\\s?", ""), 15, mutedColor);
|
||||||
quote.setPadding(dp(context, 10), dp(context, 6), dp(context, 8), dp(context, 6));
|
quote.setPadding(dp(context, 10), dp(context, 6), dp(context, 8), dp(context, 6));
|
||||||
quote.setBackgroundColor(palette.blockBackground);
|
quote.setBackgroundColor(palette.blockBackground);
|
||||||
parent.addView(quote);
|
parent.addView(quote);
|
||||||
} else if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
} 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);
|
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));
|
bullet.setPadding(dp(context, 8), dp(context, 2), 0, dp(context, 2));
|
||||||
parent.addView(bullet);
|
parent.addView(bullet);
|
||||||
} else {
|
} else {
|
||||||
paragraph.add(line);
|
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);
|
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*)+\\|?$");
|
&& 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;
|
if (paragraph.isEmpty()) return;
|
||||||
TextView p = text(context, String.join("\n", paragraph), 15, textColor);
|
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.setMovementMethod(LinkMovementMethod.getInstance());
|
||||||
p.setPadding(0, dp(context, 2), 0, dp(context, 6));
|
p.setPadding(0, dp(context, 2), 0, dp(context, 6));
|
||||||
parent.addView(p);
|
parent.addView(p);
|
||||||
@@ -160,7 +179,8 @@ final class MarkdownRenderer {
|
|||||||
parent.addView(line, params);
|
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);
|
HorizontalScrollView scroll = new HorizontalScrollView(context);
|
||||||
TableLayout table = new TableLayout(context);
|
TableLayout table = new TableLayout(context);
|
||||||
table.setShrinkAllColumns(false);
|
table.setShrinkAllColumns(false);
|
||||||
@@ -172,7 +192,8 @@ final class MarkdownRenderer {
|
|||||||
for (String cell : cells) {
|
for (String cell : cells) {
|
||||||
String cellText = cell.trim();
|
String cellText = cell.trim();
|
||||||
TextView tv = text(context, "", 14, r == 0 ? textColor : mutedColor);
|
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.setTypeface(r == 0 ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT);
|
||||||
tv.setPadding(dp(context, 10), dp(context, 8), dp(context, 10), dp(context, 8));
|
tv.setPadding(dp(context, 10), dp(context, 8), dp(context, 10), dp(context, 8));
|
||||||
tv.setBackgroundColor(r == 0 ? palette.tableHeaderBackground : palette.tableCellBackground);
|
tv.setBackgroundColor(r == 0 ? palette.tableHeaderBackground : palette.tableCellBackground);
|
||||||
@@ -253,7 +274,11 @@ final class MarkdownRenderer {
|
|||||||
return tv;
|
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();
|
SpannableStringBuilder out = new SpannableStringBuilder();
|
||||||
int i = 0;
|
int i = 0;
|
||||||
while (i < value.length()) {
|
while (i < value.length()) {
|
||||||
@@ -277,12 +302,108 @@ final class MarkdownRenderer {
|
|||||||
continue;
|
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));
|
out.append(value.charAt(i));
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
return out;
|
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) {
|
static int dp(Context context, int value) {
|
||||||
return Math.round(value * context.getResources().getDisplayMetrics().density);
|
return Math.round(value * context.getResources().getDisplayMetrics().density);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user