Skip to content

Commit a2fbabf

Browse files
authored
Merge pull request #2040 from nextcloud/fix/noid/add-rtl-support-for-invitations-string
fix(localization): Add RTL languages support for invitations and typing indicator strings
2 parents f49fdbc + 9017ac6 commit a2fbabf

File tree

6 files changed

+208
-132
lines changed

6 files changed

+208
-132
lines changed

NextcloudTalk.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@
468468
2C1EF36D25505DCE007C9768 /* NCNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2C1EF36A25505DCE007C9768 /* NCNavigationController.m */; };
469469
2C21446E2BB5B54D005A6537 /* BaseChatTableViewCell+Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C21446D2BB5B54D005A6537 /* BaseChatTableViewCell+Location.swift */; };
470470
2C2145682BF6B8E900470C0C /* NewRoomTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2145672BF6B8E900470C0C /* NewRoomTableViewController.swift */; };
471+
2C24ED842D9C0B2B004F48CF /* NSAttributedStringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C24ED832D9C0B2B004F48CF /* NSAttributedStringExtension.swift */; };
472+
2C24ED852D9C14F8004F48CF /* NSAttributedStringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C24ED832D9C0B2B004F48CF /* NSAttributedStringExtension.swift */; };
471473
2C2A788E2359CC8800EEB797 /* NCAppBranding.m in Sources */ = {isa = PBXBuildFile; fileRef = 2C2A788D2359CC8800EEB797 /* NCAppBranding.m */; };
472474
2C2D7A172B8C9C0000642373 /* RoomCreationTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2D7A162B8C9C0000642373 /* RoomCreationTableViewController.swift */; };
473475
2C2E64251F3462AF00D39CE8 /* NCSignalingMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = 2C2E64241F3462AF00D39CE8 /* NCSignalingMessage.m */; };
@@ -991,6 +993,7 @@
991993
2C1EF36A25505DCE007C9768 /* NCNavigationController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NCNavigationController.m; sourceTree = "<group>"; };
992994
2C21446D2BB5B54D005A6537 /* BaseChatTableViewCell+Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseChatTableViewCell+Location.swift"; sourceTree = "<group>"; };
993995
2C2145672BF6B8E900470C0C /* NewRoomTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewRoomTableViewController.swift; sourceTree = "<group>"; };
996+
2C24ED832D9C0B2B004F48CF /* NSAttributedStringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSAttributedStringExtension.swift; sourceTree = "<group>"; };
994997
2C2A788C2359CC8800EEB797 /* NCAppBranding.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NCAppBranding.h; sourceTree = "<group>"; };
995998
2C2A788D2359CC8800EEB797 /* NCAppBranding.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NCAppBranding.m; sourceTree = "<group>"; };
996999
2C2D7A162B8C9C0000642373 /* RoomCreationTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomCreationTableViewController.swift; sourceTree = "<group>"; };
@@ -1683,6 +1686,7 @@
16831686
2C9200C12474262C0050084F /* UIBarButtonItem+Badge.h */,
16841687
2C9200C22474262C0050084F /* UIBarButtonItem+Badge.m */,
16851688
2CEDA88B26F492610044552B /* NSMutableAttributedString+Extensions.swift */,
1689+
2C24ED832D9C0B2B004F48CF /* NSAttributedStringExtension.swift */,
16861690
1FDFC94C2BA50B9100670DF4 /* UIFontExtension.swift */,
16871691
2CBD0D592C8770A40013C089 /* UIImageExtension.swift */,
16881692
1FAB2EED2AD1BC1B001214EB /* UIControlExtensions.swift */,
@@ -3271,6 +3275,7 @@
32713275
2CA1CCD01F1E1779002FE6A2 /* SearchTableViewController.m in Sources */,
32723276
1F1C0D8929AFB89900D17C6D /* VLCKitVideoViewController.swift in Sources */,
32733277
2C1C68072D51229500A7F98A /* CalendarEvent.swift in Sources */,
3278+
2C24ED842D9C0B2B004F48CF /* NSAttributedStringExtension.swift in Sources */,
32743279
2C9B0B98217F6DBA00A4752C /* NCNotificationController.m in Sources */,
32753280
2CC3166E2CC698E1007CBE16 /* TextFieldTableViewCell.swift in Sources */,
32763281
2C36A04A261487BC0026F04A /* DetailedOptionsSelectorTableViewController.m in Sources */,
@@ -3449,6 +3454,7 @@
34493454
2CB6ACDC2641483800D3D641 /* NCMessageLocationParameter.m in Sources */,
34503455
1F35F8E82AEEBC0800044BDA /* SLKTextView+SLKAdditions.m in Sources */,
34513456
1F35F9052AEEDF0E00044BDA /* AutoCompletionTableViewCell.m in Sources */,
3457+
2C24ED852D9C14F8004F48CF /* NSAttributedStringExtension.swift in Sources */,
34523458
1F35F90A2AEEE76A00044BDA /* QuotedMessageView.m in Sources */,
34533459
2C62B02E24C1BDD7007E460A /* PlaceholderView.m in Sources */,
34543460
1F7CCC262D552D2000F3FB77 /* Mention.swift in Sources */,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
//
5+
6+
import Foundation
7+
8+
extension NSAttributedString {
9+
10+
/// Initializes an attributed string by replacing placeholders with provided arguments.
11+
///
12+
/// - Parameters:
13+
/// - format: The attributed string containing placeholders (only allowed `%@` or positional ones like`%1$@`).
14+
/// - args: The arguments to replace the placeholders.
15+
convenience init?(format: NSAttributedString, _ args: CVarArg...) {
16+
let mutableAttributedString = NSMutableAttributedString(attributedString: format)
17+
18+
// Regex patterns for positional placeholders (%1$@, %2$@,…) and non-positional placeholder (%@)
19+
let positionalRegexPattern = "%(\\d+)\\$@"
20+
let nonPositionalRegexPattern = "%@"
21+
22+
guard let positionalRegex = try? NSRegularExpression(pattern: positionalRegexPattern, options: []),
23+
let nonPositionalRegex = try? NSRegularExpression(pattern: nonPositionalRegexPattern, options: []) else {
24+
print("Regex creation failed")
25+
return nil
26+
}
27+
28+
let positionalPlaceholders = positionalRegex.matches(in: mutableAttributedString.string, range: NSRange(location: 0, length: mutableAttributedString.length))
29+
let containsPositionalPlaceholders = !positionalPlaceholders.isEmpty
30+
let regex = containsPositionalPlaceholders ? positionalRegex : nonPositionalRegex
31+
32+
guard (containsPositionalPlaceholders && positionalPlaceholders.count == args.count) ||
33+
(!containsPositionalPlaceholders && args.count == 1) else {
34+
print("Incorrect number of arguments")
35+
return nil
36+
}
37+
38+
while let match = regex.firstMatch(in: mutableAttributedString.string, range: NSRange(location: 0, length: mutableAttributedString.length)) {
39+
let matchRange = match.range
40+
var replacementArg: CVarArg?
41+
42+
if containsPositionalPlaceholders, match.numberOfRanges > 1,
43+
// Get range of the capture group (\d+) in the positional regex %(\d+)\$@ and convert it into a Range<String.Index>
44+
let range = Range(match.range(at: 1), in: mutableAttributedString.string),
45+
let position = Int(mutableAttributedString.string[range]), position > 0, position <= args.count {
46+
replacementArg = args[position - 1]
47+
} else if !args.isEmpty {
48+
replacementArg = args.first
49+
}
50+
51+
// If there's no valid argument to replace, something went wrong
52+
guard let arg = replacementArg else {
53+
print("Missing argument for placeholder at range \(matchRange)")
54+
return nil
55+
}
56+
57+
// Convert the argument to an attributed string
58+
let replacement: NSAttributedString
59+
if let attributedStringArg = arg as? NSAttributedString {
60+
replacement = attributedStringArg
61+
} else {
62+
replacement = NSAttributedString(string: "\(arg)")
63+
}
64+
65+
mutableAttributedString.replaceCharacters(in: matchRange, with: replacement)
66+
}
67+
68+
self.init(attributedString: mutableAttributedString)
69+
}
70+
}

NextcloudTalk/ScheduleMeetingSwiftUIView.swift

Lines changed: 27 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -161,63 +161,43 @@ struct ScheduleMeetingSwiftUIView: View {
161161
}
162162

163163
private func footerString() -> String {
164-
var localizedSuffix: String
164+
var localizedText: String
165165

166166
if self.selectedParticipants.isEmpty {
167167
return NSLocalizedString("Sending no invitations", comment: "")
168-
169168
} else if self.selectedParticipants.count == 1 {
170-
localizedSuffix = NSLocalizedString("will receive an invitation", comment: "Alice will receive an invitation")
171-
172-
} else if self.selectedParticipants.count == 2 || self.selectedParticipants.count == 3 {
173-
localizedSuffix = NSLocalizedString("will receive invitations", comment: "Alice and Bob will receive invitations")
169+
localizedText = String(
170+
format: NSLocalizedString("%@ will receive an invitation", comment: "Alice will receive an invitation"),
171+
self.selectedParticipants[0].displayName
172+
)
173+
174+
} else if self.selectedParticipants.count == 2 {
175+
localizedText = String(
176+
format: NSLocalizedString("%@ and %@ will receive invitations", comment: "Alice and Bob will receive invitations"),
177+
self.selectedParticipants[0].displayName, self.selectedParticipants[1].displayName
178+
)
179+
180+
} else if self.selectedParticipants.count == 3 {
181+
localizedText = String(
182+
format: NSLocalizedString("%@, %@ and %@ will receive invitations", comment: "Alice, Bob and Charlie will receive invitations"),
183+
self.selectedParticipants[0].displayName, self.selectedParticipants[1].displayName, self.selectedParticipants[2].displayName
184+
)
174185

175186
} else if self.selectedParticipants.count == 4 {
176-
localizedSuffix = NSLocalizedString("and 1 other will receive invitations", comment: "Alice, Bob, Charlie and 1 other is typing…")
187+
localizedText = String(
188+
format: NSLocalizedString("%@, %@, %@ and 1 other will receive invitations", comment: "Alice, Bob, Charlie and 1 other will receive invitations"),
189+
self.selectedParticipants[0].displayName, self.selectedParticipants[1].displayName, self.selectedParticipants[2].displayName
190+
)
177191

178192
} else {
179-
let localizedString = NSLocalizedString("and %ld others will receive invitations", comment: "Alice, Bob, Charlie and 3 others will receive invitations")
180-
localizedSuffix = String(format: localizedString, self.selectedParticipants.count - 3)
193+
let othersCount = self.selectedParticipants.count - 3
194+
localizedText = String(
195+
format: NSLocalizedString("%@, %@, %@ and %ld others will receive invitations", comment: "Alice, Bob, Charlie and 3 others will receive invitations"),
196+
self.selectedParticipants[0].displayName, self.selectedParticipants[1].displayName, self.selectedParticipants[2].displayName, othersCount
197+
)
181198
}
182199

183-
return getParticipantsString() + " " + localizedSuffix
184-
}
185-
186-
private func getParticipantsString() -> String {
187-
if self.selectedParticipants.count == 1,
188-
let user1 = self.selectedParticipants[0].displayName {
189-
// Alice
190-
return user1
191-
192-
} else {
193-
let separator = ", "
194-
let separatorSpace = " "
195-
let separatorLast = NSLocalizedString("and", comment: "Alice and Bob")
196-
197-
if self.selectedParticipants.count == 2,
198-
let user1 = self.selectedParticipants[0].displayName,
199-
let user2 = self.selectedParticipants[1].displayName {
200-
// Alice and Bob
201-
return user1 + separatorSpace + separatorLast + separatorSpace + user2
202-
203-
} else if self.selectedParticipants.count == 3,
204-
let user1 = self.selectedParticipants[0].displayName,
205-
let user2 = self.selectedParticipants[1].displayName,
206-
let user3 = self.selectedParticipants[2].displayName {
207-
// Alice, Bob and Charlie
208-
return user1 + separator + user2 + separatorSpace + separatorLast + separatorSpace + user3
209-
210-
} else if let user1 = self.selectedParticipants[0].displayName,
211-
let user2 = self.selectedParticipants[1].displayName,
212-
let user3 = self.selectedParticipants[2].displayName {
213-
214-
// Alice, Bob, Charlie
215-
return user1 + separator + user2 + separator + user3
216-
217-
} else {
218-
return NSLocalizedString("Participants", comment: "")
219-
}
220-
}
200+
return localizedText
221201
}
222202

223203
private func initStartEndTimes() {

NextcloudTalk/TypingIndicatorView.swift

Lines changed: 40 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -62,77 +62,59 @@ import SwiftyAttributes
6262
self.removeTimer?.invalidate()
6363
}
6464

65-
private func getUsersTypingString() -> NSAttributedString {
66-
// Array keep the order of the elements, no need to sort here manually
67-
if self.typingUsers.count == 1 {
68-
// Alice
69-
return self.typingUsers[0].displayName.withTextColor(.secondaryLabel)
70-
71-
} else {
72-
let separator = ", ".withTextColor(.tertiaryLabel)
73-
let separatorSpace = NSAttributedString(string: " ")
74-
let separatorLast = NSLocalizedString("and", comment: "Alice and Bob").withTextColor(.tertiaryLabel)
75-
76-
if self.typingUsers.count == 2 {
77-
// Alice and Bob
78-
let user1 = self.typingUsers[0].displayName.withTextColor(.secondaryLabel)
79-
let user2 = self.typingUsers[1].displayName.withTextColor(.secondaryLabel)
80-
81-
return user1 + separatorSpace + separatorLast + separatorSpace + user2
82-
83-
} else if self.typingUsers.count == 3 {
84-
// Alice, Bob and Charlie
85-
let user1 = self.typingUsers[0].displayName.withTextColor(.secondaryLabel)
86-
let user2 = self.typingUsers[1].displayName.withTextColor(.secondaryLabel)
87-
let user3 = self.typingUsers[2].displayName.withTextColor(.secondaryLabel)
88-
89-
return user1 + separator + user2 + separatorSpace + separatorLast + separatorSpace + user3
90-
91-
} else {
92-
// Alice, Bob, Charlie
93-
let user1 = self.typingUsers[0].displayName.withTextColor(.secondaryLabel)
94-
let user2 = self.typingUsers[1].displayName.withTextColor(.secondaryLabel)
95-
let user3 = self.typingUsers[2].displayName.withTextColor(.secondaryLabel)
96-
97-
return user1 + separator + user2 + separator + user3
98-
}
99-
}
100-
}
101-
102-
private func updateTypingIndicator() {
65+
internal func updateTypingIndicator() {
10366
if self.typingUsers.isEmpty {
10467
// Just hide the label to have a nice animation. Otherwise we would animate an empty label/space
10568
self.isVisible = false
10669
} else {
107-
let attributedSpace = NSAttributedString(string: " ")
108-
var localizedSuffix: NSAttributedString
70+
var localizedAttributedString: NSAttributedString?
10971

11072
if self.typingUsers.count == 1 {
111-
localizedSuffix = NSLocalizedString("is typing…", comment: "Alice is typing…").withTextColor(.tertiaryLabel)
73+
let unformattedAttributedString = NSLocalizedString("%@ is typing…", comment: "Alice is typing…").withTextColor(.tertiaryLabel)
74+
localizedAttributedString = NSAttributedString(format: unformattedAttributedString,
75+
self.typingUsers[0].displayName.withTextColor(.secondaryLabel))
11276

113-
} else if self.typingUsers.count == 2 || self.typingUsers.count == 3 {
114-
localizedSuffix = NSLocalizedString("are typing…", comment: "Alice and Bob are typing…").withTextColor(.tertiaryLabel)
77+
} else if self.typingUsers.count == 2 {
78+
let unformattedAttributedString = NSLocalizedString("%1$@ and %2$@ are typing…", comment: "Alice and Bob are typing…").withTextColor(.tertiaryLabel)
79+
localizedAttributedString = NSAttributedString(format: unformattedAttributedString,
80+
self.typingUsers[0].displayName.withTextColor(.secondaryLabel),
81+
self.typingUsers[1].displayName.withTextColor(.secondaryLabel))
11582

116-
} else if self.typingUsers.count == 4 {
117-
localizedSuffix = NSLocalizedString("and 1 other is typing…", comment: "Alice, Bob, Charlie and 1 other is typing…").withTextColor(.tertiaryLabel)
83+
} else if self.typingUsers.count == 3 {
84+
let unformattedAttributedString = NSLocalizedString("%1$@, %2$@ and %3$@ are typing…", comment: "Alice, Bob and Charlie are typing…").withTextColor(.tertiaryLabel)
85+
localizedAttributedString = NSAttributedString(format: unformattedAttributedString,
86+
self.typingUsers[0].displayName.withTextColor(.secondaryLabel),
87+
self.typingUsers[1].displayName.withTextColor(.secondaryLabel),
88+
self.typingUsers[2].displayName.withTextColor(.secondaryLabel))
11889

90+
} else if self.typingUsers.count == 4 {
91+
let unformattedAttributedString = NSLocalizedString("%1$@, %2$@, %3$@ and 1 other is typing…", comment: "Alice, Bob, Charlie and 1 other is typing…").withTextColor(.tertiaryLabel)
92+
localizedAttributedString = NSAttributedString(format: unformattedAttributedString,
93+
self.typingUsers[0].displayName.withTextColor(.secondaryLabel),
94+
self.typingUsers[1].displayName.withTextColor(.secondaryLabel),
95+
self.typingUsers[2].displayName.withTextColor(.secondaryLabel))
11996
} else {
120-
let localizedString = NSLocalizedString("and %ld others are typing…", comment: "Alice, Bob, Charlie and 3 others are typing…")
121-
let formattedString = String(format: localizedString, self.typingUsers.count - 3)
122-
localizedSuffix = formattedString.withTextColor(.tertiaryLabel)
97+
let othersCount = self.typingUsers.count - 3
98+
let unformattedAttributedString = NSLocalizedString("%1$@, %2$@, %3$@ and %4$@ others are typing…", comment: "Alice, Bob, Charlie and 3 others are typing…").withTextColor(.tertiaryLabel)
99+
localizedAttributedString = NSAttributedString(format: unformattedAttributedString,
100+
self.typingUsers[0].displayName.withTextColor(.secondaryLabel),
101+
self.typingUsers[1].displayName.withTextColor(.secondaryLabel),
102+
self.typingUsers[2].displayName.withTextColor(.secondaryLabel),
103+
othersCount)
123104
}
124105

125-
UIView.transition(with: self.typingLabel,
126-
duration: 0.2,
127-
options: .transitionCrossDissolve,
128-
animations: {
129-
130-
let newTypingText = self.getUsersTypingString() + attributedSpace + localizedSuffix
131-
132-
self.typingLabel.attributedText = newTypingText.withFont(.preferredFont(forTextStyle: .body))
133-
}, completion: nil)
106+
if let localizedAttributedString {
107+
UIView.transition(with: self.typingLabel,
108+
duration: 0.2,
109+
options: .transitionCrossDissolve,
110+
animations: {
111+
self.typingLabel.attributedText = localizedAttributedString.withFont(.preferredFont(forTextStyle: .body))
112+
}, completion: nil)
134113

135-
self.isVisible = true
114+
self.isVisible = true
115+
} else {
116+
self.isVisible = false
117+
}
136118
}
137119

138120
self.previousUpdateTimestamp = Date().timeIntervalSinceReferenceDate

0 commit comments

Comments
 (0)