feat(sharing): hidden-profile toggle + hide non-sharing group members
- UserProfile decodes directory_hidden; updateProfile() sends it; Settings has a "hide my profile" toggle (excludes you from share/group pickers). - GroupMember decodes shares_calendar; the group filter list now only shows members who actually share a calendar (no phantom empty rows). - Robust custom decoders (decodeIfPresent) for the new fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -223,6 +223,7 @@ struct UserProfile: Codable {
|
|||||||
let isAdmin: Bool
|
let isAdmin: Bool
|
||||||
let hasAvatar: Bool
|
let hasAvatar: Bool
|
||||||
let totpEnabled: Bool
|
let totpEnabled: Bool
|
||||||
|
var directoryHidden: Bool = false
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, username, email
|
case id, username, email
|
||||||
@@ -230,6 +231,19 @@ struct UserProfile: Codable {
|
|||||||
case isAdmin = "is_admin"
|
case isAdmin = "is_admin"
|
||||||
case hasAvatar = "has_avatar"
|
case hasAvatar = "has_avatar"
|
||||||
case totpEnabled = "totp_enabled"
|
case totpEnabled = "totp_enabled"
|
||||||
|
case directoryHidden = "directory_hidden"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
username = try c.decode(String.self, forKey: .username)
|
||||||
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||||
|
email = try c.decodeIfPresent(String.self, forKey: .email)
|
||||||
|
isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin) ?? false
|
||||||
|
hasAvatar = try c.decodeIfPresent(Bool.self, forKey: .hasAvatar) ?? false
|
||||||
|
totpEnabled = try c.decodeIfPresent(Bool.self, forKey: .totpEnabled) ?? false
|
||||||
|
directoryHidden = try c.decodeIfPresent(Bool.self, forKey: .directoryHidden) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,9 +272,20 @@ struct GroupMember: Codable, Identifiable {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
var role: String
|
var role: String
|
||||||
var color: String?
|
var color: String?
|
||||||
|
var sharesCalendar: Bool = true
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, role, color
|
case id, role, color
|
||||||
case displayName = "display_name"
|
case displayName = "display_name"
|
||||||
|
case sharesCalendar = "shares_calendar"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||||
|
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "member"
|
||||||
|
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||||
|
sharesCalendar = try c.decodeIfPresent(Bool.self, forKey: .sharesCalendar) ?? true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.privacy": "Privatsphäre",
|
"settings.privacy": "Privatsphäre",
|
||||||
"settings.private_visibility": "Private Termine für Gruppen",
|
"settings.private_visibility": "Private Termine für Gruppen",
|
||||||
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
|
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
|
||||||
|
"settings.directory_hidden": "Profil verbergen",
|
||||||
|
"settings.directory_hidden.desc": "Nicht in Teilen-/Gruppen-Auswahl anzeigen. In der Admin-Benutzerverwaltung bleibst du sichtbar.",
|
||||||
"settings.private.busy": "Als „Beschäftigt“",
|
"settings.private.busy": "Als „Beschäftigt“",
|
||||||
"settings.private.hidden": "Ausblenden",
|
"settings.private.hidden": "Ausblenden",
|
||||||
"settings.calendars": "Geteilter Kalender",
|
"settings.calendars": "Geteilter Kalender",
|
||||||
@@ -461,6 +463,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.privacy": "Privacy",
|
"settings.privacy": "Privacy",
|
||||||
"settings.private_visibility": "Private events for groups",
|
"settings.private_visibility": "Private events for groups",
|
||||||
"settings.private_visibility.desc": "How your private events appear to other group members",
|
"settings.private_visibility.desc": "How your private events appear to other group members",
|
||||||
|
"settings.directory_hidden": "Hide my profile",
|
||||||
|
"settings.directory_hidden.desc": "Don't show me in share/group pickers. You stay visible in the admin user management.",
|
||||||
"settings.private.busy": "Show as \"Busy\"",
|
"settings.private.busy": "Show as \"Busy\"",
|
||||||
"settings.private.hidden": "Hide",
|
"settings.private.hidden": "Hide",
|
||||||
"settings.calendars": "Shared calendar",
|
"settings.calendars": "Shared calendar",
|
||||||
|
|||||||
@@ -450,11 +450,13 @@ class CalendarrAPI {
|
|||||||
|
|
||||||
/// Update profile fields. A login-name change returns a fresh token (the old
|
/// Update profile fields. A login-name change returns a fresh token (the old
|
||||||
/// one becomes invalid) — the caller must store the returned token.
|
/// one becomes invalid) — the caller must store the returned token.
|
||||||
func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? {
|
func updateProfile(displayName: String?, username: String?, email: String?,
|
||||||
|
directoryHidden: Bool? = nil) async throws -> String? {
|
||||||
var body: [String: Any] = [:]
|
var body: [String: Any] = [:]
|
||||||
if let d = displayName { body["display_name"] = d }
|
if let d = displayName { body["display_name"] = d }
|
||||||
if let u = username { body["username"] = u }
|
if let u = username { body["username"] = u }
|
||||||
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
|
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
|
||||||
|
if let h = directoryHidden { body["directory_hidden"] = h }
|
||||||
let data = try await request("/api/profile/", method: "PUT", body: body)
|
let data = try await request("/api/profile/", method: "PUT", body: body)
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
return json?["access_token"] as? String
|
return json?["access_token"] as? String
|
||||||
|
|||||||
@@ -217,7 +217,9 @@ struct CalendarFilterSheet: View {
|
|||||||
if let g = groupDetail {
|
if let g = groupDetail {
|
||||||
List {
|
List {
|
||||||
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
|
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
|
||||||
ForEach(g.members ?? []) { m in
|
// Only members who actually share a calendar into the group —
|
||||||
|
// avoids phantom empty rows for members who share nothing.
|
||||||
|
ForEach((g.members ?? []).filter { $0.sharesCalendar }) { m in
|
||||||
groupRow(name: m.displayName ?? "—",
|
groupRow(name: m.displayName ?? "—",
|
||||||
colorHex: m.color ?? "#4285f4",
|
colorHex: m.color ?? "#4285f4",
|
||||||
key: CalendarStore.groupMemberKey(m.id))
|
key: CalendarStore.groupMemberKey(m.id))
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ struct SettingsView: View {
|
|||||||
@State private var privateVisibility = "busy"
|
@State private var privateVisibility = "busy"
|
||||||
@State private var groupVisibleId = 0 // 0 = none
|
@State private var groupVisibleId = 0 // 0 = none
|
||||||
@State private var ownLocalCals: [LocalCalendar] = []
|
@State private var ownLocalCals: [LocalCalendar] = []
|
||||||
|
@State private var directoryHidden = false
|
||||||
@State private var profileMsg = ""
|
@State private var profileMsg = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -102,6 +103,13 @@ struct SettingsView: View {
|
|||||||
.keyboardType(.emailAddress)
|
.keyboardType(.emailAddress)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
}
|
}
|
||||||
|
Toggle(isOn: $directoryHidden) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(L10n.t("settings.directory_hidden", appLang))
|
||||||
|
Text(L10n.t("settings.directory_hidden.desc", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
|
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
|
||||||
if !profileMsg.isEmpty {
|
if !profileMsg.isEmpty {
|
||||||
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
|
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
|
||||||
@@ -188,6 +196,7 @@ struct SettingsView: View {
|
|||||||
displayName = p.displayName ?? p.username
|
displayName = p.displayName ?? p.username
|
||||||
loginName = p.username
|
loginName = p.username
|
||||||
email = p.email ?? ""
|
email = p.email ?? ""
|
||||||
|
directoryHidden = p.directoryHidden
|
||||||
}
|
}
|
||||||
if let s = try? await api.getSettings() {
|
if let s = try? await api.getSettings() {
|
||||||
privateVisibility = s.privateEventVisibility
|
privateVisibility = s.privateEventVisibility
|
||||||
@@ -202,7 +211,8 @@ struct SettingsView: View {
|
|||||||
do {
|
do {
|
||||||
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
|
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
|
||||||
username: nil,
|
username: nil,
|
||||||
email: email.isEmpty ? "" : email)
|
email: email.isEmpty ? "" : email,
|
||||||
|
directoryHidden: directoryHidden)
|
||||||
UserDefaults.standard.set(displayName, forKey: "displayName")
|
UserDefaults.standard.set(displayName, forKey: "displayName")
|
||||||
profileMsg = L10n.t("settings.saved", appLang)
|
profileMsg = L10n.t("settings.saved", appLang)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user