From 3382b06e93d70e8461a39de18f88d75ea14f1263 Mon Sep 17 00:00:00 2001 From: WEIREN FENG Date: Wed, 14 Oct 2026 13:22:03 -0700 Subject: [PATCH] Meetups: a refused join reads in the phone's language The server refuses a join in English sentences, one or several of a meetup's rules at once, and the app showed them as they came: a phone set to Chinese read "Meetup is full." in English. MeetupRefusal puts each sentence the server can send into the phone's language. The rules are the lines the meetup screen already shows before joining, with their translations; three refusals of the server's own are new to the catalog. A sentence it does not know is shown as the server wrote it. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios-native/App/Localizable.xcstrings | 30 ++++++++ ios-native/Features/Meetups/Meetups.swift | 72 ++++++++++++++++++- .../PetNoteAppTests/PlacesMeetupsTests.swift | 46 ++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/ios-native/App/Localizable.xcstrings b/ios-native/App/Localizable.xcstrings index 20a3f61..dc0cac6 100644 --- a/ios-native/App/Localizable.xcstrings +++ b/ios-native/App/Localizable.xcstrings @@ -3826,6 +3826,26 @@ } } }, + "Meetup is full.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这场聚会已满。" + } + } + } + }, + "Meetup is no longer accepting participants.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这场聚会已不再接受报名。" + } + } + } + }, "Meetups": { "localizations": { "zh-Hans": { @@ -5729,6 +5749,16 @@ } } }, + "Select a pet to join this meetup.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选一只宠物来报名这场聚会。" + } + } + } + }, "Send": { "localizations": { "zh-Hans": { diff --git a/ios-native/Features/Meetups/Meetups.swift b/ios-native/Features/Meetups/Meetups.swift index e48b6c1..dac2e44 100644 --- a/ios-native/Features/Meetups/Meetups.swift +++ b/ios-native/Features/Meetups/Meetups.swift @@ -302,6 +302,75 @@ enum MeetupJoinOutcome: Equatable, Sendable { case refused(String) } +/// The server's reason for refusing a join, in the phone's language. +/// +/// It refuses in English sentences, one or several of a meetup's rules at +/// once. Each one it can send is either a line `MeetupRequirements.lines` +/// already shows before joining, or one of three of its own. They are put +/// into the phone's language one by one. A sentence this does not know is +/// shown as the server wrote it, rather than not at all. +enum MeetupRefusal { + static func localized(_ reason: String, bundle: Bundle = .main, locale: Locale = .current) -> String { + var words = "" + for sentence in sentences(of: reason) { + // English sentences are spaced; Chinese ones are not. + if words.hasSuffix(".") { words += " " } + words += localized(sentence: sentence, bundle: bundle, locale: locale) + } + return words + } + + /// The server joins its reasons with a space, each ending in a full stop. + static func sentences(of reason: String) -> [String] { + var sentences: [String] = [] + var current = "" + for character in reason.trimmingCharacters(in: .whitespaces) { + if character == " ", current.hasSuffix(".") { + sentences.append(current) + current = "" + } else { + current.append(character) + } + } + if !current.isEmpty { sentences.append(current) } + return sentences + } + + private static func localized(sentence: String, bundle: Bundle, locale: Locale) -> String { + switch sentence { + case "Meetup is full.": + return String(localized: "Meetup is full.", bundle: bundle, locale: locale) + case "Meetup is no longer accepting participants.": + return String(localized: "Meetup is no longer accepting participants.", bundle: bundle, locale: locale) + case "Select a pet to join this meetup.": + return String(localized: "Select a pet to join this meetup.", bundle: bundle, locale: locale) + case "Must have posted at least once.": + return String(localized: "Must have posted at least once.", bundle: bundle, locale: locale) + case "Must have a pet profile.": + return String(localized: "Must have a pet profile.", bundle: bundle, locale: locale) + case "Dogs only.": + return String(localized: "Dogs only.", bundle: bundle, locale: locale) + case "Cats only.": + return String(localized: "Cats only.", bundle: bundle, locale: locale) + case "Other pets only.": + return String(localized: "Other pets only.", bundle: bundle, locale: locale) + default: + // "Requires at least 3 followed pets." The server says "pets" + // for one as well; the app does not. + guard let count = followedPets(in: sentence) else { return sentence } + return count == 1 + ? String(localized: "Requires at least 1 followed pet.", bundle: bundle, locale: locale) + : String(localized: "Requires at least \(count) followed pets.", bundle: bundle, locale: locale) + } + } + + private static func followedPets(in sentence: String) -> Int? { + let prefix = "Requires at least ", suffix = " followed pets." + guard sentence.hasPrefix(prefix), sentence.hasSuffix(suffix) else { return nil } + return Int(sentence.dropFirst(prefix.count).dropLast(suffix.count)) + } +} + protocol MeetupsReading: Sendable { func upcoming(limit: Int) async throws -> [Meetup] func thisWeek(from now: Date, limit: Int) async throws -> [Meetup] @@ -426,7 +495,8 @@ actor FirestoreMeetupsSource: MeetupsReading, MeetupCreating, MeetupEditing { if let petID { payload["petId"] = petID } let answer = try await CallableClient.call(Callables.joinMeetup, payload) if answer["success"] as? Bool == true { return .joined } - return .refused(answer["error"] as? String ?? String(localized: "Couldn't join this meetup.")) + if let reason = answer["error"] as? String { return .refused(MeetupRefusal.localized(reason)) } + return .refused(String(localized: "Couldn't join this meetup.")) } func leave(meetupID: String, uid: String) async throws { diff --git a/ios-native/PetNoteAppTests/PlacesMeetupsTests.swift b/ios-native/PetNoteAppTests/PlacesMeetupsTests.swift index 06a9156..1e1ca1e 100644 --- a/ios-native/PetNoteAppTests/PlacesMeetupsTests.swift +++ b/ios-native/PetNoteAppTests/PlacesMeetupsTests.swift @@ -521,3 +521,49 @@ struct PlacesMeetupsTests { #expect(!model.hasMore) } } + +/// The server refuses a join in English sentences. The app shows them in the +/// phone's language, one sentence at a time. +struct MeetupRefusalTests { + /// A bundle with no translations in it: what English gets, whatever + /// language the machine running the tests is set to. + private final class NoTranslations {} + private static let english = Bundle(for: NoTranslations.self) + private static let zh = Locale(identifier: "zh-Hans") + + private static func chinese() throws -> Bundle { + let path = try #require(Bundle.main.path(forResource: "zh-Hans", ofType: "lproj"), "the app was built without Chinese") + return try #require(Bundle(path: path)) + } + + @Test func theServersOwnRefusalsComeOutInChinese() throws { + let bundle = try Self.chinese() + #expect(MeetupRefusal.localized("Meetup is full.", bundle: bundle, locale: Self.zh) == "这场聚会已满。") + #expect(MeetupRefusal.localized("Meetup is no longer accepting participants.", bundle: bundle, locale: Self.zh) + == "这场聚会已不再接受报名。") + #expect(MeetupRefusal.localized("Select a pet to join this meetup.", bundle: bundle, locale: Self.zh) == "选一只宠物来报名这场聚会。") + } + + @Test func severalRulesAtOnceAreEachPutIntoChinese() throws { + let words = MeetupRefusal.localized( + "Must have posted at least once. Requires at least 3 followed pets. Dogs only.", + bundle: try Self.chinese(), locale: Self.zh + ) + #expect(words == "需要至少发过一条帖子。需要至少关注 3 只宠物。仅限狗。") + } + + @Test func inEnglishTheServersWordsAreUnchanged() { + let reason = "Must have posted at least once. Must have a pet profile. Cats only." + #expect(MeetupRefusal.localized(reason, bundle: Self.english) == reason) + #expect(MeetupRefusal.localized("Meetup is full.", bundle: Self.english) == "Meetup is full.") + } + + /// The server says "pets" for one as well. + @Test func oneFollowedPetIsOnePet() { + #expect(MeetupRefusal.localized("Requires at least 1 followed pets.", bundle: Self.english) == "Requires at least 1 followed pet.") + } + + @Test func aSentenceItDoesNotKnowIsShownAsTheServerWroteIt() throws { + #expect(MeetupRefusal.localized("Something new. Dogs only.", bundle: try Self.chinese(), locale: Self.zh) == "Something new. 仅限狗。") + } +}