Add Spanish Suffixes grammar guide with card-based content layout
Add comprehensive suffix reference (diminutives, augmentatives, verb endings, noun/adjective-forming, adverbs, pejoratives) as grammar note #21 under Word Building category. Refactor grammar detail rendering to group paragraphs with their examples into visual cards, replacing the flat wall-of-text layout. Suffix entries get pill-styled labels with compact inline examples. Bump courseDataVersion to 5. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ struct GrammarNote: Identifiable {
|
||||
accentMarksStress,
|
||||
seConstructions,
|
||||
estarGerundProgressive,
|
||||
spanishSuffixes,
|
||||
]
|
||||
|
||||
// MARK: - 1. Ser vs Estar
|
||||
@@ -887,4 +888,215 @@ struct GrammarNote: Identifiable {
|
||||
**Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years.
|
||||
"""
|
||||
)
|
||||
|
||||
// MARK: - 21. Spanish Suffixes
|
||||
|
||||
private static let spanishSuffixes = GrammarNote(
|
||||
id: "spanish-suffixes",
|
||||
title: "Spanish Suffixes",
|
||||
category: "Word Building",
|
||||
body: """
|
||||
Spanish uses suffixes — endings added to root words — to change meaning, size, tone, or part of speech. Learning these patterns lets you decode unfamiliar words and build new ones from roots you already know.
|
||||
|
||||
**Diminutives — smallness and affection**
|
||||
|
||||
Diminutive suffixes make a noun feel smaller, cuter, or more endearing. They are extremely common in everyday speech.
|
||||
|
||||
*-ito / -ita* — the most common diminutive. Expresses smallness or affection.
|
||||
*perro → perrito* — little dog
|
||||
*casa → casita* — little house
|
||||
*momento → momentito* — just a moment
|
||||
*abuela → abuelita* — grandma (affectionate)
|
||||
|
||||
*-cito / -cita* — used after words ending in a consonant or -e.
|
||||
*café → cafecito* — a nice little coffee
|
||||
*pobre → pobrecito* — poor little thing
|
||||
*amor → amorcito* — sweetheart
|
||||
|
||||
*-illo / -illa* — a slightly less affectionate diminutive.
|
||||
*chico → chiquillo* — little kid
|
||||
*guerra → guerrilla* — small war / guerrilla
|
||||
*pan → panecillo* — bread roll
|
||||
|
||||
*-ico / -ica* — regional diminutive popular in Colombia, Cuba, and Costa Rica.
|
||||
*rato → ratico* — a little while
|
||||
*gato → gatico* — little cat
|
||||
|
||||
**Augmentatives — bigness and intensity**
|
||||
|
||||
Augmentative suffixes make nouns feel bigger, more intense, or sometimes clumsier. They often carry a slightly negative or humorous tone.
|
||||
|
||||
*-ón / -ona* — big or intense, sometimes negative.
|
||||
*silla → sillón* — armchair (big chair)
|
||||
*soltar → solterón* — confirmed bachelor
|
||||
*cabeza → cabezón* — big-headed / stubborn
|
||||
|
||||
*-ote / -ota* — big, often clumsy or exaggerated.
|
||||
*grande → grandote* — really big
|
||||
*amigo → amigote* — a big buddy (casual)
|
||||
*palabra → palabrota* — a swear word (a "big word")
|
||||
|
||||
*-azo / -aza* — big; also means a blow or hit with something.
|
||||
*perro → perrazo* — huge dog
|
||||
*puño → puñetazo* — a punch
|
||||
*gol → golazo* — an amazing goal
|
||||
*codo → codazo* — an elbow jab
|
||||
|
||||
*-udo / -uda* — having a lot of something.
|
||||
*pelo → peludo* — hairy
|
||||
*barba → barbudo* — bearded
|
||||
*panza → panzudo* — big-bellied
|
||||
|
||||
**Verb-related endings**
|
||||
|
||||
These endings are built into the verb conjugation system and tell you when and how the action occurs.
|
||||
|
||||
*-ando* — gerund for -ar verbs (happening now, equivalent to English "-ing").
|
||||
*hablar → hablando* — speaking
|
||||
*caminar → caminando* — walking
|
||||
*estudiar → estudiando* — studying
|
||||
|
||||
*-iendo* — gerund for -er and -ir verbs.
|
||||
*comer → comiendo* — eating
|
||||
*vivir → viviendo* — living
|
||||
*leer → leyendo* — reading (note the spelling change)
|
||||
|
||||
*-ado* — past participle for -ar verbs (equivalent to English "-ed").
|
||||
*hablar → hablado* — spoken
|
||||
*cerrar → cerrado* — closed
|
||||
*cansar → cansado* — tired
|
||||
|
||||
*-ido* — past participle for -er and -ir verbs.
|
||||
*comer → comido* — eaten
|
||||
*vivir → vivido* — lived
|
||||
*dormir → dormido* — slept
|
||||
|
||||
**Noun-forming suffixes**
|
||||
|
||||
These suffixes turn verbs or adjectives into nouns, creating words for actions, results, places, and people.
|
||||
|
||||
*-ción / -sión* — action or result, equivalent to English "-tion."
|
||||
*actuar → acción* — action
|
||||
*decidir → decisión* — decision
|
||||
*comunicar → comunicación* — communication
|
||||
*expresar → expresión* — expression
|
||||
|
||||
*-miento* — action or result, similar to -ción.
|
||||
*conocer → conocimiento* — knowledge
|
||||
*sentir → sentimiento* — feeling
|
||||
*mover → movimiento* — movement
|
||||
|
||||
*-ería* — a shop, business, or place associated with something.
|
||||
*pan → panadería* — bakery
|
||||
*libro → librería* — bookstore
|
||||
*zapato → zapatería* — shoe store
|
||||
*carne → carnicería* — butcher shop
|
||||
|
||||
*-ero / -era* — a person who does or makes something.
|
||||
*pan → panadero* — baker
|
||||
*cocina → cocinero* — cook
|
||||
*carta → cartero* — mail carrier
|
||||
*enfermo → enfermera* — nurse
|
||||
|
||||
*-dor / -dora* — a person or thing that does something.
|
||||
*jugar → jugador* — player
|
||||
*trabajar → trabajador* — worker
|
||||
*computar → computadora* — computer
|
||||
*lavar → lavadora* — washing machine
|
||||
|
||||
*-ista* — a person devoted to something (gender-neutral form).
|
||||
*arte → artista* — artist
|
||||
*diente → dentista* — dentist
|
||||
*fútbol → futbolista* — soccer player
|
||||
*piano → pianista* — pianist
|
||||
|
||||
*-ura* — a quality or result.
|
||||
*dulce → dulzura* — sweetness
|
||||
*loco → locura* — madness
|
||||
*abrir → abertura* — opening
|
||||
*pintar → pintura* — painting / paint
|
||||
|
||||
*-eza* — an abstract quality.
|
||||
*bello → belleza* — beauty
|
||||
*triste → tristeza* — sadness
|
||||
*puro → pureza* — purity
|
||||
*natural → naturaleza* — nature
|
||||
|
||||
*-dad / -tad* — a quality, equivalent to English "-ty."
|
||||
*libre → libertad* — liberty
|
||||
*real → realidad* — reality
|
||||
*feliz → felicidad* — happiness
|
||||
*amigo → amistad* — friendship
|
||||
|
||||
*-anza* — action or result.
|
||||
*esperar → esperanza* — hope
|
||||
*enseñar → enseñanza* — teaching
|
||||
*confiar → confianza* — trust / confidence
|
||||
|
||||
**Adjective-forming suffixes**
|
||||
|
||||
These turn nouns or verbs into adjectives that describe qualities, origins, or tendencies.
|
||||
|
||||
*-oso / -osa* — full of something, equivalent to English "-ous."
|
||||
*peligro → peligroso* — dangerous
|
||||
*hermosura → hermoso* — beautiful
|
||||
*cariño → cariñoso* — affectionate
|
||||
*éxito → exitoso* — successful
|
||||
|
||||
*-ano / -ana* — relating to a place or group.
|
||||
*México → mexicano* — Mexican
|
||||
*América → americano* — American
|
||||
*cristiano* — Christian
|
||||
*humano* — human
|
||||
|
||||
*-eño / -eña* — from a place.
|
||||
*Panamá → panameño* — Panamanian
|
||||
*Brasil → brasileño* — Brazilian
|
||||
*isla → isleño* — islander
|
||||
|
||||
*-ense* — from a place (often countries or cities).
|
||||
*Costa Rica → costarricense* — Costa Rican
|
||||
*Estados Unidos → estadounidense* — American (USA)
|
||||
*Canadá → canadiense* — Canadian
|
||||
|
||||
*-ble* — able to be, equivalent to English "-ble."
|
||||
*posible* — possible
|
||||
*increíble* — incredible
|
||||
*responsable* — responsible
|
||||
*agradable* — pleasant
|
||||
|
||||
*-ivo / -iva* — tending toward, equivalent to English "-ive."
|
||||
*actuar → activo* — active
|
||||
*crear → creativo* — creative
|
||||
*producir → productivo* — productive
|
||||
|
||||
**Adverb and intensity suffixes**
|
||||
|
||||
*-mente* — turns adjectives into adverbs, equivalent to English "-ly." Attach it to the feminine form of the adjective.
|
||||
*rápida → rápidamente* — quickly
|
||||
*fácil → fácilmente* — easily
|
||||
*tranquila → tranquilamente* — calmly
|
||||
*completa → completamente* — completely
|
||||
|
||||
*-ísimo / -ísima* — absolute superlative, meaning "extremely" or "super."
|
||||
*rápido → rapidísimo* — super fast
|
||||
*bella → bellísima* — extremely beautiful
|
||||
*mucho → muchísimo* — very very much
|
||||
*grande → grandísimo* — enormous
|
||||
|
||||
**Pejorative suffixes — negative or ugly tone**
|
||||
|
||||
These suffixes add a negative, derogatory, or ugly connotation.
|
||||
|
||||
*-ucho / -ucha* — ugly, run-down.
|
||||
*casa → casucha* — a shack, a dump
|
||||
*delgado → delgaducho* — scrawny
|
||||
|
||||
*-aco / -aca* — ugly, derogatory.
|
||||
*pájaro → pajarraco* — ugly bird
|
||||
*libro → libraco* — a terrible book
|
||||
|
||||
**Key insight:** Once you recognize root + suffix patterns, you can decode unfamiliar words. For example, *panadería* = *pan* (bread) + *-ero* (person) + *-ía* (place) = "place where the bread-person works" = bakery. The suffix system makes Spanish vocabulary highly predictable and composable.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ actor DataLoader {
|
||||
/// Re-seed course data if the version has changed (e.g. examples were added).
|
||||
/// Call this on every launch — it checks a version key and only re-seeds when needed.
|
||||
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
|
||||
let currentVersion = 4 // Bump this whenever course_data.json changes
|
||||
let currentVersion = 5 // Bump this whenever course_data.json changes
|
||||
let key = "courseDataVersion"
|
||||
let shared = UserDefaults.standard
|
||||
|
||||
|
||||
@@ -101,40 +101,148 @@ struct GrammarNoteDetailView: View {
|
||||
private struct FormattedGrammarBody: View {
|
||||
let content: String
|
||||
|
||||
private var lines: [GrammarLine] {
|
||||
GrammarLine.parse(content)
|
||||
private var sections: [GrammarSection] {
|
||||
GrammarSection.group(GrammarLine.parse(content))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(lines) { line in
|
||||
switch line.kind {
|
||||
case .paragraph(let text):
|
||||
renderParagraph(text)
|
||||
case .spanishExample(let spanish):
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
.padding(.leading, 12)
|
||||
case .examplePair(let spanish, let english):
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
Text(english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(sections) { section in
|
||||
if section.heading == nil {
|
||||
// Intro content before the first heading — no card
|
||||
renderLines(section.lines)
|
||||
} else {
|
||||
// Headed section in a card
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(section.heading!)
|
||||
.font(.headline)
|
||||
|
||||
renderLines(section.lines)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
case .heading(let text):
|
||||
Text(text)
|
||||
.font(.headline)
|
||||
.padding(.top, 6)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderLines(_ lines: [GrammarLine]) -> some View {
|
||||
let blocks = ContentBlock.group(lines)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(blocks) { block in
|
||||
switch block.kind {
|
||||
case .standalone(let line):
|
||||
renderSingleLine(line)
|
||||
case .exampleCard(let header, let examples):
|
||||
renderExampleCard(header: header, examples: examples)
|
||||
case .suffixCard(let suffix, let description, let examples):
|
||||
renderSuffixCard(suffix: suffix, description: description, examples: examples)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderSingleLine(_ line: GrammarLine) -> some View {
|
||||
switch line.kind {
|
||||
case .paragraph(let text):
|
||||
renderParagraph(text)
|
||||
case .spanishExample(let spanish):
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
.padding(.leading, 12)
|
||||
case .examplePair(let spanish, let english):
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
Text(english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
case .heading, .suffixDef:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderExampleCard(header: GrammarLine?, examples: [GrammarLine]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let header, case .paragraph(let text) = header.kind {
|
||||
renderParagraph(text)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(examples) { ex in
|
||||
if case .examplePair(let spanish, let english) = ex.kind {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
Text(english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if case .spanishExample(let spanish) = ex.kind {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderSuffixCard(suffix: String, description: String, examples: [GrammarLine]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(suffix)
|
||||
.font(.subheadline.weight(.bold).monospaced())
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.tint.opacity(0.12), in: Capsule())
|
||||
|
||||
Text(description)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !examples.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(examples) { ex in
|
||||
if case .examplePair(let spanish, let english) = ex.kind {
|
||||
HStack(spacing: 0) {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
Text(" ")
|
||||
Text(english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if case .spanishExample(let spanish) = ex.kind {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderParagraph(_ text: String) -> some View {
|
||||
Text(parseInlineFormatting(text))
|
||||
@@ -200,11 +308,20 @@ private struct GrammarLine: Identifiable {
|
||||
let id: Int
|
||||
let kind: Kind
|
||||
|
||||
var isExample: Bool {
|
||||
switch kind {
|
||||
case .examplePair, .spanishExample: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
case paragraph(String)
|
||||
case heading(String)
|
||||
case spanishExample(String)
|
||||
case examplePair(spanish: String, english: String)
|
||||
/// A suffix definition line like `*-ito / -ita* — description`
|
||||
case suffixDef(suffix: String, description: String)
|
||||
}
|
||||
|
||||
static func parse(_ body: String) -> [GrammarLine] {
|
||||
@@ -255,7 +372,13 @@ private struct GrammarLine: Identifiable {
|
||||
let englishPart = String(line[dashRange.upperBound...])
|
||||
.replacingOccurrences(of: "*", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
|
||||
|
||||
// Detect suffix definitions: spanish part starts with "-"
|
||||
if spanishPart.hasPrefix("-") {
|
||||
result.append(GrammarLine(id: result.count, kind: .suffixDef(suffix: spanishPart, description: englishPart)))
|
||||
} else {
|
||||
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
|
||||
}
|
||||
} else {
|
||||
// Just a Spanish example without translation
|
||||
let spanish = line.replacingOccurrences(of: "*", with: "")
|
||||
@@ -279,6 +402,126 @@ private struct GrammarLine: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Block Grouping
|
||||
|
||||
/// Groups paragraphs with their trailing examples into visual cards.
|
||||
/// Also handles suffix definitions as a special card type.
|
||||
private struct ContentBlock: Identifiable {
|
||||
let id: Int
|
||||
|
||||
enum Kind {
|
||||
/// A standalone line (paragraph with no examples, or orphaned example)
|
||||
case standalone(GrammarLine)
|
||||
/// A paragraph header followed by example pairs — rendered as a card
|
||||
case exampleCard(header: GrammarLine?, examples: [GrammarLine])
|
||||
/// A suffix definition with its examples — rendered as a pill card
|
||||
case suffixCard(suffix: String, description: String, examples: [GrammarLine])
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
|
||||
static func group(_ lines: [GrammarLine]) -> [ContentBlock] {
|
||||
var result: [ContentBlock] = []
|
||||
var i = 0
|
||||
|
||||
while i < lines.count {
|
||||
let line = lines[i]
|
||||
|
||||
// Suffix definition: collect trailing examples
|
||||
if case .suffixDef(let suffix, let desc) = line.kind {
|
||||
var examples: [GrammarLine] = []
|
||||
var j = i + 1
|
||||
while j < lines.count, lines[j].isExample {
|
||||
examples.append(lines[j])
|
||||
j += 1
|
||||
}
|
||||
result.append(ContentBlock(id: result.count, kind: .suffixCard(suffix: suffix, description: desc, examples: examples)))
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// Paragraph followed by examples: group into a card
|
||||
if case .paragraph = line.kind {
|
||||
var j = i + 1
|
||||
// Check if examples follow
|
||||
if j < lines.count, lines[j].isExample {
|
||||
var examples: [GrammarLine] = []
|
||||
while j < lines.count, lines[j].isExample {
|
||||
examples.append(lines[j])
|
||||
j += 1
|
||||
}
|
||||
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: line, examples: examples)))
|
||||
i = j
|
||||
} else {
|
||||
// Standalone paragraph
|
||||
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
|
||||
i += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Orphaned examples (no preceding paragraph) — group into a card
|
||||
if line.isExample {
|
||||
var examples: [GrammarLine] = []
|
||||
var j = i
|
||||
while j < lines.count, lines[j].isExample {
|
||||
examples.append(lines[j])
|
||||
j += 1
|
||||
}
|
||||
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: nil, examples: examples)))
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip headings (handled at section level)
|
||||
if case .heading = line.kind {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
|
||||
i += 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grammar Section Grouping
|
||||
|
||||
private struct GrammarSection: Identifiable {
|
||||
let id: Int
|
||||
let heading: String?
|
||||
let lines: [GrammarLine]
|
||||
|
||||
static func group(_ lines: [GrammarLine]) -> [GrammarSection] {
|
||||
var sections: [GrammarSection] = []
|
||||
var currentHeading: String? = nil
|
||||
var currentLines: [GrammarLine] = []
|
||||
var sectionIndex = 0
|
||||
|
||||
for line in lines {
|
||||
if case .heading(let text) = line.kind {
|
||||
// Flush the previous section
|
||||
if !currentLines.isEmpty || currentHeading != nil {
|
||||
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
|
||||
sectionIndex += 1
|
||||
currentLines = []
|
||||
}
|
||||
currentHeading = text
|
||||
} else {
|
||||
currentLines.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush final section
|
||||
if !currentLines.isEmpty || currentHeading != nil {
|
||||
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hashable/Equatable conformance for NavigationLink
|
||||
|
||||
extension GrammarNote: Hashable, Equatable {
|
||||
|
||||
Reference in New Issue
Block a user