Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions ios-native/App/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -5729,6 +5749,16 @@
}
}
},
"Select a pet to join this meetup.": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "选一只宠物来报名这场聚会。"
}
}
}
},
"Send": {
"localizations": {
"zh-Hans": {
Expand Down
72 changes: 71 additions & 1 deletion ios-native/Features/Meetups/Meetups.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the locale from the app localization

When PetNote is set to Chinese independently in Settings while the device locale remains English, the default .current selects English even though Bundle.main.preferredLocalizations selects Chinese, so these refusals still disagree with the rest of the interface. The repository already accounts for this distinction in PostAge.interfaceLocale; derive the default locale from the bundle (or let normal bundle localization choose it). The new tests miss this production path because they always inject zh-Hans explicitly.

Useful? React with 👍 / 👎.

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]
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions ios-native/PetNoteAppTests/PlacesMeetupsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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. 仅限狗。")
}
}
Loading