mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat: confirm AMSAT report uploads
This commit is contained in:
1 parent
1d54ac4718
commit
ee32c7a1eb
4 files changed
+162
-33
No files matched your search
@@ -54,12 +54,17 @@
|
|||||||
<string name="amsat_no_reports">此时间段无报告</string>
|
<string name="amsat_no_reports">此时间段无报告</string>
|
||||||
<string name="amsat_close">关闭</string>
|
<string name="amsat_close">关闭</string>
|
||||||
<string name="amsat_upload">上传</string>
|
<string name="amsat_upload">上传</string>
|
||||||
|
<string name="amsat_upload_back">返回</string>
|
||||||
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
<string name="amsat_upload_report">上传报告到 AMSAT</string>
|
||||||
<string name="amsat_upload_status">状态</string>
|
<string name="amsat_upload_status">状态</string>
|
||||||
<string name="amsat_upload_callsign">呼号</string>
|
<string name="amsat_upload_callsign">呼号</string>
|
||||||
<string name="amsat_upload_grid">网格</string>
|
<string name="amsat_upload_grid">网格</string>
|
||||||
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
|
||||||
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
<string name="amsat_upload_submit">提交到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm">确认上传</string>
|
||||||
|
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
|
||||||
|
<string name="amsat_upload_grid_none">未填写</string>
|
||||||
<string name="amsat_upload_success">报告已上传</string>
|
<string name="amsat_upload_success">报告已上传</string>
|
||||||
<string name="amsat_upload_failed">上传失败:%s</string>
|
<string name="amsat_upload_failed">上传失败:%s</string>
|
||||||
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
<string name="amsat_upload_callsign_required">请填写呼号</string>
|
||||||
|
|||||||
@@ -71,12 +71,17 @@
|
|||||||
<string name="amsat_no_reports">No reports for this period</string>
|
<string name="amsat_no_reports">No reports for this period</string>
|
||||||
<string name="amsat_close">Close</string>
|
<string name="amsat_close">Close</string>
|
||||||
<string name="amsat_upload">Upload</string>
|
<string name="amsat_upload">Upload</string>
|
||||||
|
<string name="amsat_upload_back">Back</string>
|
||||||
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
<string name="amsat_upload_report">Upload report to AMSAT</string>
|
||||||
<string name="amsat_upload_status">Status</string>
|
<string name="amsat_upload_status">Status</string>
|
||||||
<string name="amsat_upload_callsign">Callsign</string>
|
<string name="amsat_upload_callsign">Callsign</string>
|
||||||
<string name="amsat_upload_grid">Grid</string>
|
<string name="amsat_upload_grid">Grid</string>
|
||||||
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
<string name="amsat_upload_time_now">Time: current UTC %s</string>
|
||||||
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
<string name="amsat_upload_submit">Submit to AMSAT</string>
|
||||||
|
<string name="amsat_upload_confirm">Upload</string>
|
||||||
|
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
|
||||||
|
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
|
||||||
|
<string name="amsat_upload_grid_none">Not provided</string>
|
||||||
<string name="amsat_upload_success">Report uploaded</string>
|
<string name="amsat_upload_success">Report uploaded</string>
|
||||||
<string name="amsat_upload_failed">Upload failed: %s</string>
|
<string name="amsat_upload_failed">Upload failed: %s</string>
|
||||||
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
<string name="amsat_upload_callsign_required">Callsign is required</string>
|
||||||
|
|||||||
+65
-8
@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
@@ -113,7 +114,9 @@ fun SatStatusDestination() {
|
|||||||
onUploadReportChange = viewModel::setUploadReport,
|
onUploadReportChange = viewModel::setUploadReport,
|
||||||
onUploadCallsignChange = viewModel::setUploadCallsign,
|
onUploadCallsignChange = viewModel::setUploadCallsign,
|
||||||
onUploadGridChange = viewModel::setUploadGrid,
|
onUploadGridChange = viewModel::setUploadGrid,
|
||||||
onSubmitUpload = viewModel::submitReport,
|
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
|
||||||
|
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
|
||||||
|
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
|
||||||
onDismissUpload = viewModel::collapseUploadPanel
|
onDismissUpload = viewModel::collapseUploadPanel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,7 +129,9 @@ private fun SatStatusScreen(
|
|||||||
onUploadReportChange: (String) -> Unit,
|
onUploadReportChange: (String) -> Unit,
|
||||||
onUploadCallsignChange: (String) -> Unit,
|
onUploadCallsignChange: (String) -> Unit,
|
||||||
onUploadGridChange: (String) -> Unit,
|
onUploadGridChange: (String) -> Unit,
|
||||||
onSubmitUpload: (String) -> Unit,
|
onRequestSubmitUpload: () -> Unit,
|
||||||
|
onConfirmSubmitUpload: (String) -> Unit,
|
||||||
|
onDismissSubmitConfirmation: () -> Unit,
|
||||||
onDismissUpload: () -> Unit
|
onDismissUpload: () -> Unit
|
||||||
) {
|
) {
|
||||||
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||||
@@ -172,7 +177,9 @@ private fun SatStatusScreen(
|
|||||||
onReportChange = onUploadReportChange,
|
onReportChange = onUploadReportChange,
|
||||||
onCallsignChange = onUploadCallsignChange,
|
onCallsignChange = onUploadCallsignChange,
|
||||||
onGridChange = onUploadGridChange,
|
onGridChange = onUploadGridChange,
|
||||||
onSubmitReport = { onSubmitUpload(status.name) },
|
onRequestSubmitReport = onRequestSubmitUpload,
|
||||||
|
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
|
||||||
|
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
selectedDay = null
|
selectedDay = null
|
||||||
onDismissUpload()
|
onDismissUpload()
|
||||||
@@ -342,7 +349,9 @@ private fun ReportDialog(
|
|||||||
onReportChange: (String) -> Unit,
|
onReportChange: (String) -> Unit,
|
||||||
onCallsignChange: (String) -> Unit,
|
onCallsignChange: (String) -> Unit,
|
||||||
onGridChange: (String) -> Unit,
|
onGridChange: (String) -> Unit,
|
||||||
onSubmitReport: () -> Unit,
|
onRequestSubmitReport: () -> Unit,
|
||||||
|
onConfirmSubmitReport: () -> Unit,
|
||||||
|
onDismissSubmitConfirmation: () -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
|
||||||
@@ -351,7 +360,10 @@ private fun ReportDialog(
|
|||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
onAccept = onDismiss,
|
onAccept = onDismiss,
|
||||||
extraAction = {
|
extraAction = {
|
||||||
CardButton(onClick = onToggleUpload, text = stringResource(R.string.amsat_upload))
|
CardButton(
|
||||||
|
onClick = onToggleUpload,
|
||||||
|
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = upload.isExpanded) {
|
AnimatedVisibility(visible = upload.isExpanded) {
|
||||||
@@ -361,7 +373,15 @@ private fun ReportDialog(
|
|||||||
onReportChange = onReportChange,
|
onReportChange = onReportChange,
|
||||||
onCallsignChange = onCallsignChange,
|
onCallsignChange = onCallsignChange,
|
||||||
onGridChange = onGridChange,
|
onGridChange = onGridChange,
|
||||||
onSubmitReport = onSubmitReport
|
onRequestSubmitReport = onRequestSubmitReport
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (upload.isConfirmingSubmit) {
|
||||||
|
AmSatUploadConfirmDialog(
|
||||||
|
statusName = statusName,
|
||||||
|
upload = upload,
|
||||||
|
onConfirm = onConfirmSubmitReport,
|
||||||
|
onDismiss = onDismissSubmitConfirmation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (dayReports.isEmpty()) {
|
if (dayReports.isEmpty()) {
|
||||||
@@ -405,7 +425,7 @@ private fun AmSatUploadPanel(
|
|||||||
onReportChange: (String) -> Unit,
|
onReportChange: (String) -> Unit,
|
||||||
onCallsignChange: (String) -> Unit,
|
onCallsignChange: (String) -> Unit,
|
||||||
onGridChange: (String) -> Unit,
|
onGridChange: (String) -> Unit,
|
||||||
onSubmitReport: () -> Unit
|
onRequestSubmitReport: () -> Unit
|
||||||
) {
|
) {
|
||||||
val options = remember(statusName) {
|
val options = remember(statusName) {
|
||||||
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
|
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
|
||||||
@@ -488,7 +508,7 @@ private fun AmSatUploadPanel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = onSubmitReport,
|
onClick = onRequestSubmitReport,
|
||||||
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
|
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
@@ -517,6 +537,43 @@ private fun AmSatUploadPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AmSatUploadConfirmDialog(
|
||||||
|
statusName: String,
|
||||||
|
upload: AmSatUploadUiState,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
|
||||||
|
val statusLabel = stringResource(option.labelResId)
|
||||||
|
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.amsat_upload_confirm_message,
|
||||||
|
statusName,
|
||||||
|
statusLabel,
|
||||||
|
upload.callsign,
|
||||||
|
grid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
|
||||||
|
Text(stringResource(R.string.amsat_upload_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
|
||||||
|
Text(stringResource(R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun uploadErrorText(error: String): String {
|
private fun uploadErrorText(error: String): String {
|
||||||
return when (error) {
|
return when (error) {
|
||||||
|
|||||||
+87
-25
@@ -31,6 +31,7 @@ data class AmSatUploadUiState(
|
|||||||
val callsign: String = "",
|
val callsign: String = "",
|
||||||
val gridSquare: String = "",
|
val gridSquare: String = "",
|
||||||
val isSubmitting: Boolean = false,
|
val isSubmitting: Boolean = false,
|
||||||
|
val isConfirmingSubmit: Boolean = false,
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
@@ -125,56 +126,75 @@ class SatStatusViewModel(
|
|||||||
error = null
|
error = null
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
upload.copy(isExpanded = false, message = null, error = null)
|
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun collapseUploadPanel() {
|
fun collapseUploadPanel() {
|
||||||
_uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, message = null, error = null)) }
|
_uiState.update { it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setUploadReport(report: String) {
|
fun setUploadReport(report: String) {
|
||||||
_uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, message = null, error = null)) }
|
_uiState.update { it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setUploadCallsign(callsign: String) {
|
fun setUploadCallsign(callsign: String) {
|
||||||
val normalized = callsign.trim().uppercase(Locale.US)
|
val normalized = callsign.trim().uppercase(Locale.US)
|
||||||
settingsRepo.setAmSatCallsign(normalized)
|
settingsRepo.setAmSatCallsign(normalized)
|
||||||
_uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, message = null, error = null)) }
|
_uiState.update { it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setUploadGrid(grid: String) {
|
fun setUploadGrid(grid: String) {
|
||||||
val normalized = grid.trim().uppercase(Locale.US)
|
val normalized = grid.trim().uppercase(Locale.US)
|
||||||
_uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, message = null, error = null)) }
|
_uiState.update { it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun submitReport(satelliteName: String) {
|
fun requestSubmitConfirmation() {
|
||||||
|
val prepared = validateUpload(_uiState.value.upload) ?: return
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
isConfirmingSubmit = true,
|
||||||
|
message = null,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissSubmitConfirmation() {
|
||||||
|
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmSubmitReport(satelliteName: String) {
|
||||||
val upload = _uiState.value.upload
|
val upload = _uiState.value.upload
|
||||||
if (upload.isSubmitting) return
|
if (upload.isSubmitting) return
|
||||||
val callsign = upload.callsign.trim().uppercase(Locale.US)
|
val prepared = validateUpload(upload) ?: return
|
||||||
val grid = upload.gridSquare.trim().uppercase(Locale.US)
|
|
||||||
when {
|
|
||||||
callsign.isBlank() -> {
|
|
||||||
_uiState.update { it.copy(upload = it.upload.copy(error = UPLOAD_ERROR_CALLSIGN_REQUIRED, message = null)) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
|
|
||||||
_uiState.update { it.copy(upload = it.upload.copy(error = UPLOAD_ERROR_GRID_INVALID, message = null)) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsRepo.setAmSatCallsign(callsign)
|
settingsRepo.setAmSatCallsign(prepared.callsign)
|
||||||
_uiState.update { it.copy(upload = it.upload.copy(isSubmitting = true, error = null, message = null)) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
callsign = prepared.callsign,
|
||||||
|
gridSquare = prepared.grid,
|
||||||
|
isSubmitting = true,
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = null,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = amSatRepo.submitReport(
|
val result = amSatRepo.submitReport(
|
||||||
AmSatReportSubmission(
|
AmSatReportSubmission(
|
||||||
name = satelliteName,
|
name = satelliteName,
|
||||||
report = upload.selectedReport,
|
report = prepared.report,
|
||||||
callsign = callsign,
|
callsign = prepared.callsign,
|
||||||
gridSquare = grid,
|
gridSquare = prepared.grid,
|
||||||
reportedAtUtcMillis = System.currentTimeMillis()
|
reportedAtUtcMillis = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -183,8 +203,8 @@ class SatStatusViewModel(
|
|||||||
it.copy(
|
it.copy(
|
||||||
upload = it.upload.copy(
|
upload = it.upload.copy(
|
||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
callsign = callsign,
|
callsign = prepared.callsign,
|
||||||
gridSquare = grid,
|
gridSquare = prepared.grid,
|
||||||
message = UPLOAD_MESSAGE_SUCCESS,
|
message = UPLOAD_MESSAGE_SUCCESS,
|
||||||
error = null
|
error = null
|
||||||
)
|
)
|
||||||
@@ -205,6 +225,42 @@ class SatStatusViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
|
||||||
|
val callsign = upload.callsign.trim().uppercase(Locale.US)
|
||||||
|
val grid = upload.gridSquare.trim().uppercase(Locale.US)
|
||||||
|
return when {
|
||||||
|
callsign.isBlank() -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
upload = it.upload.copy(
|
||||||
|
isConfirmingSubmit = false,
|
||||||
|
error = UPLOAD_ERROR_GRID_INVALID,
|
||||||
|
message = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> PreparedUpload(
|
||||||
|
report = upload.selectedReport,
|
||||||
|
callsign = callsign,
|
||||||
|
grid = grid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun defaultGridSquare(): String {
|
private fun defaultGridSquare(): String {
|
||||||
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
|
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
|
||||||
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
|
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
|
||||||
@@ -219,4 +275,10 @@ class SatStatusViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class PreparedUpload(
|
||||||
|
val report: String,
|
||||||
|
val callsign: String,
|
||||||
|
val grid: String
|
||||||
|
)
|
||||||
|
|
||||||
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
|
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
|
||||||
Reference in new issue
Block a user