feat(keyboard): add clipboard history and undo pastes
Ship optional keyboard clipboard history with settings, suggestion strip, and paste-permission guidance; let undo roll back clipboard inserts; bump build to 64.
This commit is contained in:
@@ -56,17 +56,31 @@ struct AIKeyboardView: View {
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var topBar: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
if state.canCancelAIInput {
|
||||
if state.canCancelAIInput {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
KeyboardCancelButton(
|
||||
action: state.cancelAIInput,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.ai.cancel"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.ai.cancelHint")
|
||||
)
|
||||
} else {
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
|
||||
// Replaces logo + capsule tabs until dismissed.
|
||||
ClipboardSuggestionBar(
|
||||
text: suggestion,
|
||||
onInsert: { state.insertClipboardText(suggestion) },
|
||||
onDismiss: state.dismissClipboardSuggestion
|
||||
)
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
} else {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
@@ -74,8 +88,8 @@ struct AIKeyboardView: View {
|
||||
onInsert: onInsert
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
private var answerArea: some View {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// ClipboardKeyboardViews.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Clipboard suggestion strip, enable-guide sheet, and history panel.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Suggestion strip (Doubao-style)
|
||||
|
||||
struct ClipboardSuggestionBar: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let text: String
|
||||
let onInsert: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "clipboard")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Button(action: onInsert) {
|
||||
Text(text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
KeyboardCancelButton(
|
||||
action: onDismiss,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.clipboard.suggestion.dismissA11y"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.clipboard.suggestion.dismissHint")
|
||||
)
|
||||
}
|
||||
.frame(height: KeyboardTopBarMetrics.height)
|
||||
// No fill — sit in the logo/tab slot over the system keyboard chrome.
|
||||
.background(Color.clear)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared panel header
|
||||
|
||||
/// Title (+ optional accessory) on the leading edge, cancel X on the trailing
|
||||
/// edge — same 12 pt inset / 44 pt row as the keyboard top bar so the X lands
|
||||
/// on the clipboard chip's slot when the overlay replaces the surface.
|
||||
private struct ClipboardPanelHeader<Accessory: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let onClose: () -> Void
|
||||
@ViewBuilder let trailingAccessory: () -> Accessory
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ExtL10n.text("keyboard.clipboard.panel.title")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
trailingAccessory()
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Same chip as edit-mode close — occupies the clipboard button slot.
|
||||
KeyboardCancelButton(
|
||||
action: onClose,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.clipboard.panel.close"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.clipboard.panel.closeHint")
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
|
||||
.frame(height: KeyboardTopBarMetrics.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enable guide
|
||||
|
||||
struct ClipboardEnableGuideView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let onClose: () -> Void
|
||||
let onOpenSettings: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ClipboardPanelHeader(
|
||||
onClose: onClose,
|
||||
trailingAccessory: { EmptyView() }
|
||||
)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
ExtL10n.text("keyboard.clipboard.guide.body")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 28)
|
||||
|
||||
Button(action: onOpenSettings) {
|
||||
ExtL10n.text("keyboard.clipboard.guide.cta")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(palette.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - History panel
|
||||
|
||||
struct ClipboardHistoryPanelView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var history: ClipboardHistoryStore
|
||||
|
||||
let onClose: () -> Void
|
||||
let onClear: () -> Void
|
||||
let onInsert: (String) -> Void
|
||||
let onDelete: (UUID) -> Void
|
||||
let pastePermissionHint: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ClipboardPanelHeader(onClose: onClose) {
|
||||
Button(action: onClear) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||
height: KeyboardTopBarMetrics.trailingChipSize
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(history.entries.isEmpty)
|
||||
.opacity(history.entries.isEmpty ? 0.35 : 1)
|
||||
}
|
||||
|
||||
if let pastePermissionHint, !pastePermissionHint.isEmpty {
|
||||
Text(pastePermissionHint)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(palette.warning)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
if history.entries.isEmpty {
|
||||
ExtL10n.text("keyboard.clipboard.panel.empty")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(history.entries) { entry in
|
||||
ClipboardHistoryRow(
|
||||
entry: entry,
|
||||
onInsert: { onInsert(entry.text) },
|
||||
onInsertToken: { onInsert($0) },
|
||||
onDelete: { onDelete(entry.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// Transparent — let the system keyboard chrome show through.
|
||||
.background(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClipboardHistoryRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let entry: ClipboardHistoryEntry
|
||||
let onInsert: () -> Void
|
||||
let onInsertToken: (String) -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
private var tokens: [String] {
|
||||
ClipboardHistoryPolicy.whitespaceTokens(from: entry.text)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Button(action: onInsert) {
|
||||
Text(entry.text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(3)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Menu {
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Label(
|
||||
ExtL10n.string("keyboard.clipboard.panel.delete"),
|
||||
systemImage: "trash"
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
if tokens.count >= 2, tokens.count <= 12 {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(tokens, id: \.self) { token in
|
||||
Button {
|
||||
onInsertToken(token)
|
||||
} label: {
|
||||
Text(token)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: Capsule()
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
// Half opacity so the keyboard chrome still reads through the card.
|
||||
.background(
|
||||
palette.surface.opacity(0.5),
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Top clipboard button (replaces translation chip slot)
|
||||
|
||||
struct KeyboardClipboardMenuButton: View, Equatable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let palette: ThemePalette
|
||||
let action: () -> Void
|
||||
|
||||
nonisolated static func == (
|
||||
lhs: KeyboardClipboardMenuButton,
|
||||
rhs: KeyboardClipboardMenuButton
|
||||
) -> Bool {
|
||||
lhs.palette == rhs.palette
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
// Neutral chip — mirrors the translation button's off state.
|
||||
Image(systemName: "clipboard")
|
||||
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||
height: KeyboardTopBarMetrics.trailingChipSize
|
||||
)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.a11y"))
|
||||
.accessibilityHint(ExtL10n.text("keyboard.clipboard.a11yHint"))
|
||||
}
|
||||
|
||||
private var buttonFill: Color {
|
||||
colorScheme == .dark ? Color(white: 0.30) : .white
|
||||
}
|
||||
}
|
||||
@@ -215,26 +215,41 @@ public struct KeyboardRootView: View {
|
||||
// MARK: - Top bar
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||
// iOS system layout); see micActionRow's bottom HStack.
|
||||
// Engine controls remain available in the host app.
|
||||
// App context is auto-detected on each mic press — no UI.
|
||||
Spacer(minLength: 0)
|
||||
Group {
|
||||
if state.canCancelVoiceInput {
|
||||
KeyboardCancelButton(
|
||||
action: state.cancelVoiceInput,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.voice.cancel"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.voice.cancelHint")
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
KeyboardCancelButton(
|
||||
action: state.cancelVoiceInput,
|
||||
accessibilityLabel: ExtL10n.text("keyboard.voice.cancel"),
|
||||
accessibilityHint: ExtL10n.text("keyboard.voice.cancelHint")
|
||||
)
|
||||
}
|
||||
} else if shouldShowClipboardSuggestion {
|
||||
// Occupies the logo + capsule-tab slot until dismissed.
|
||||
ClipboardSuggestionBar(
|
||||
text: state.clipboardSuggestionText ?? "",
|
||||
onInsert: {
|
||||
if let text = state.clipboardSuggestionText {
|
||||
state.insertClipboardText(text)
|
||||
}
|
||||
},
|
||||
onDismiss: state.dismissClipboardSuggestion
|
||||
)
|
||||
} else {
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||
// iOS system layout); see micActionRow's bottom HStack.
|
||||
Spacer(minLength: 0)
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
|
||||
@@ -269,6 +284,10 @@ public struct KeyboardRootView: View {
|
||||
disabled: editingBlocked || !state.undoAvailable,
|
||||
visible: undoVisible
|
||||
)
|
||||
} else {
|
||||
// Right-handed undo is on the trailing pad; put
|
||||
// translation on the leading (mic-left) side.
|
||||
translationButton(visible: undoVisible)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +316,10 @@ public struct KeyboardRootView: View {
|
||||
disabled: editingBlocked || !state.undoAvailable,
|
||||
visible: undoVisible
|
||||
)
|
||||
} else {
|
||||
// Default: translation sits on the mic's right,
|
||||
// symmetric with undo on the left.
|
||||
translationButton(visible: undoVisible)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,6 +439,29 @@ public struct KeyboardRootView: View {
|
||||
.accessibilityHidden(!visible)
|
||||
}
|
||||
|
||||
/// Translation chip relocated from the top bar — mirrors undo across the mic.
|
||||
private func translationButton(visible: Bool) -> some View {
|
||||
KeyboardTranslationMenuButton(
|
||||
palette: palette,
|
||||
targetLocaleId: state.translationTargetLocaleId,
|
||||
onSelect: state.setTranslationTargetLocaleId
|
||||
)
|
||||
.equatable()
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.undoButtonSize,
|
||||
height: KeyboardLayoutMetrics.undoButtonSize
|
||||
)
|
||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
||||
.opacity(visible ? 1 : 0)
|
||||
.allowsHitTesting(visible)
|
||||
.accessibilityHidden(!visible)
|
||||
}
|
||||
|
||||
private var shouldShowClipboardSuggestion: Bool {
|
||||
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func bottomSpaceButton(disabled: Bool) -> some View {
|
||||
RectangularToolbarButton(
|
||||
spaceStyle: true,
|
||||
|
||||
@@ -14,6 +14,9 @@ enum KeyboardTopBarMetrics {
|
||||
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
||||
static let logoHeight: CGFloat = 22
|
||||
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
||||
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
|
||||
static let trailingChipSize: CGFloat = 34
|
||||
static let trailingChipIconSize: CGFloat = 15
|
||||
}
|
||||
|
||||
struct KeyboardBrandLogo: View {
|
||||
@@ -49,17 +52,17 @@ struct KeyboardCancelButton: View {
|
||||
let accessibilityHint: Text
|
||||
|
||||
var body: some View {
|
||||
// Same 34×34 chip as KeyboardClipboardMenuButton / translation.
|
||||
Button(action: action) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||
height: KeyboardTopBarMetrics.trailingChipSize
|
||||
)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -135,13 +138,10 @@ struct KeyboardTopControls: View {
|
||||
.padding(2)
|
||||
.background(trackFill, in: Capsule())
|
||||
|
||||
KeyboardTranslationMenuButton(
|
||||
KeyboardClipboardMenuButton(
|
||||
palette: palette,
|
||||
targetLocaleId: state.translationTargetLocaleId,
|
||||
onSelect: state.setTranslationTargetLocaleId
|
||||
action: state.openClipboardPanel
|
||||
)
|
||||
// Decouple the open picker from the keyboard's 1 Hz App Group
|
||||
// poll so scrolling does not reset or dismiss the menu.
|
||||
.equatable()
|
||||
}
|
||||
}
|
||||
@@ -219,7 +219,7 @@ struct KeyboardTopControls: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let palette: ThemePalette
|
||||
@@ -251,29 +251,28 @@ private struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(isEnabled ? palette.accent : palette.textSecondary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(Circle().stroke(buttonStroke, lineWidth: 0.5))
|
||||
// Match the adjacent undo key: 44×44 rounded-rect chrome, not a circle chip.
|
||||
NativeKeyboardKeySurface(
|
||||
isPressed: false,
|
||||
fill: NativeKeyboardKeyColors.fill(for: colorScheme),
|
||||
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
|
||||
border: palette.divider,
|
||||
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
|
||||
) {
|
||||
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
isEnabled
|
||||
? palette.accent
|
||||
: NativeKeyboardKeyColors.text(for: colorScheme)
|
||||
)
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
||||
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
||||
}
|
||||
|
||||
private var buttonFill: Color {
|
||||
if isEnabled {
|
||||
return palette.accent.opacity(colorScheme == .dark ? 0.28 : 0.16)
|
||||
}
|
||||
return colorScheme == .dark ? Color(white: 0.30) : .white
|
||||
}
|
||||
|
||||
private var buttonStroke: Color {
|
||||
isEnabled ? palette.accent.opacity(0.35) : palette.divider
|
||||
}
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return SharedL10n.string("keyboard.translation.offMenu")
|
||||
|
||||
Reference in New Issue
Block a user