feat(magic): Hub Header mit Avatar+Nickname + iPhone/iPad via UserDevice-Locks + MacBook-Dedupe

- Neuer Endpoint /api/magic/me liefert nickname/avatar/plan fuer
  Hub-Header. Mac-App ruft fetchMe() beim Hub-Load.
- DeviceHubView Header zeigt jetzt Avatar (AsyncImage mit Fallback
  auf Initial-Letter), Nickname + Plan-Badge statt nur 'ReBreak Magic'.
- /api/magic/devices erweitert: listet zusaetzlich UserDevice-Rows mit
  boundToPlan != null (das sind iPhone/iPad aus dem Native-App-Login-
  Flow, Legend-Device-Lock). source='locked'.
- Dedupe: ProtectedDevice wird unterdrueckt wenn bereits ein UserDevice
  mit aehnlichem Namen + gleicher Plattform existiert (fixt doppelten
  MacBook im Hub).
- Helper prettyPlatform() + Normalisierung (platform-key 'mac'/'ios'/
  'android'/'win') fuer robusten Vergleich.
This commit is contained in:
chahinebrini 2026-06-03 11:41:06 +02:00
parent ac72fabc34
commit 187a2d8c19
10 changed files with 322 additions and 54 deletions

View File

@ -51,6 +51,13 @@ struct MagicReleaseResponse: Codable {
} }
} }
/// User-Profil aus /api/magic/me \u2014 f\u00fcr Hub-Header (Avatar + Nickname).
struct MagicUserProfile: Codable {
let nickname: String?
let avatar: String?
let plan: String?
}
enum MagicError: Error, LocalizedError { enum MagicError: Error, LocalizedError {
case unauthorized case unauthorized
case limitReached(activeBindings: [MagicDevice]) case limitReached(activeBindings: [MagicDevice])
@ -292,8 +299,43 @@ final class MagicAPIClient {
} }
} }
// MARK: - User Profile (Hub-Header)
func fetchMe() async throws -> MagicUserProfile {
let session = try await authService.refreshSessionIfNeeded()
let url = try URL(string: "\(baseURL)/api/magic/me")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw MagicError.networkError("Keine HTTP-Response")
}
if httpResponse.statusCode == 401 {
await authService.signOut()
throw MagicError.unauthorized
}
guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? ""
throw MagicError.httpError(httpResponse.statusCode, body)
}
struct Response: Codable {
let success: Bool
let data: MagicUserProfile
}
do {
return try JSONDecoder().decode(Response.self, from: data).data
} catch {
throw MagicError.decodingError(error.localizedDescription)
}
}
// MARK: - Download Profile // MARK: - Download Profile
func downloadProfile(token: String) async throws -> URL { func downloadProfile(token: String) async throws -> URL {
let url = try URL(string: "\(baseURL)/api/magic/profile.mobileconfig?token=\(token)")! let url = try URL(string: "\(baseURL)/api/magic/profile.mobileconfig?token=\(token)")!

View File

@ -6,6 +6,7 @@ struct DeviceHubView: View {
@Environment(WizardModel.self) private var model @Environment(WizardModel.self) private var model
@State private var devices: [MagicDevice] = [] @State private var devices: [MagicDevice] = []
@State private var profile: MagicUserProfile?
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var actionInFlight = false @State private var actionInFlight = false
@ -22,23 +23,35 @@ struct DeviceHubView: View {
content content
} }
.frame(minWidth: 720, minHeight: 540) .frame(minWidth: 720, minHeight: 540)
.task { await loadDevices() } .task {
await loadProfile()
await loadDevices()
}
} }
@ViewBuilder @ViewBuilder
private var header: some View { private var header: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "shield.lefthalf.filled") avatarView
.font(.title)
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("ReBreak Magic") Text(profile?.nickname ?? "ReBreak Magic")
.font(.title2.bold()) .font(.title3.bold())
if let email = model.authSession?.email { HStack(spacing: 6) {
Text(email) if let plan = profile?.plan {
.font(.caption) Text(plan.capitalized)
.foregroundStyle(.secondary) .font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.12))
.foregroundStyle(.blue)
.clipShape(Capsule())
}
if let host = model.authSession?.label {
Text(host)
.font(.caption)
.foregroundStyle(.secondary)
}
} }
} }
@ -228,6 +241,50 @@ struct DeviceHubView: View {
} }
} }
private func loadProfile() async {
do {
profile = try await MagicAPIClient.shared.fetchMe()
} catch {
// Profil-Fehler ist nicht-blockierend \u2014 nur Header f\u00e4llt auf Default zur\u00fcck
profile = nil
}
}
@ViewBuilder
private var avatarView: some View {
if let urlString = profile?.avatar, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
avatarFallback
}
}
.frame(width: 40, height: 40)
.clipShape(Circle())
.overlay(Circle().strokeBorder(Color.blue.opacity(0.2), lineWidth: 1))
} else {
avatarFallback
}
}
@ViewBuilder
private var avatarFallback: some View {
ZStack {
Circle().fill(Color.blue.opacity(0.15))
if let initial = (profile?.nickname?.first).map(String.init) {
Text(initial.uppercased())
.font(.headline)
.foregroundStyle(.blue)
} else {
Image(systemName: "shield.lefthalf.filled")
.foregroundStyle(.blue)
}
}
.frame(width: 40, height: 40)
}
private func handleAction(_ action: HubDeviceAction, device: MagicDevice) async { private func handleAction(_ action: HubDeviceAction, device: MagicDevice) async {
guard !actionInFlight else { return } guard !actionInFlight else { return }
actionInFlight = true actionInFlight = true

View File

@ -1,6 +1,16 @@
# Changelog # Changelog
All notable changes to rebreak-native will be documented in this file. All notable changes to rebreak-native will be documented in this file.
## v0.3.13 (Build 68 / versionCode 51) — 2026-06-03\n\n### Fixes
- DM screen: bottom gap on initial open tightened — the last message now sits directly above the input bar. The keyboard-closed padding was double-counting the input bar's own layout slot, leaving a large empty gap every time you opened a chat
- DM image lightbox: photos now actually show rounded corners — the viewer container is sized to the image's real aspect ratio (via onLoad), so the rounding lands on the visible photo instead of the empty letterbox margins of a fixed square
- Lyra coach: removed the "welcome back" greeting that popped up on every first open of the coach each session, regardless of protection status or language (it was always German and unconditional). Will return later only when it's actually warranted
- Chat list performance: the conversation list + unread badge now load via a single indexed query (one row per conversation) instead of pulling up to 500 messages and de-duplicating on the fly — added DB indexes on direct messages. Invisible to users, keeps the chat tab fast as message volume grows
### Features
- DM image lightbox: tap a shared photo → opens with a "Save" button that stores the image to your Photos library (downloads remote images first, asks for photo-add permission once). Localized DE/EN/FR/AR\n
## v0.3.13 (Build 67 / versionCode 50) — 2026-06-03\n\n### Fixes ## v0.3.13 (Build 67 / versionCode 50) — 2026-06-03\n\n### Fixes
- DM screen: bottom gap on open tightened — the last message now sits directly above the input bar instead of leaving a visible gap (reduced the keyboard/input-bar clearance padding) - DM screen: bottom gap on open tightened — the last message now sits directly above the input bar instead of leaving a visible gap (reduced the keyboard/input-bar clearance padding)

View File

@ -1,10 +0,0 @@
### Fixes
- DM screen: bottom gap on initial open tightened — the last message now sits directly above the input bar. The keyboard-closed padding was double-counting the input bar's own layout slot, leaving a large empty gap every time you opened a chat
- DM image lightbox: photos now actually show rounded corners — the viewer container is sized to the image's real aspect ratio (via onLoad), so the rounding lands on the visible photo instead of the empty letterbox margins of a fixed square
- Lyra coach: removed the "welcome back" greeting that popped up on every first open of the coach each session, regardless of protection status or language (it was always German and unconditional). Will return later only when it's actually warranted
- Chat list performance: the conversation list + unread badge now load via a single indexed query (one row per conversation) instead of pulling up to 500 messages and de-duplicating on the fly — added DB indexes on direct messages. Invisible to users, keeps the chat tab fast as message volume grows
### Features
- DM image lightbox: tap a shared photo → opens with a "Save" button that stores the image to your Photos library (downloads remote images first, asks for photo-add permission once). Localized DE/EN/FR/AR

View File

@ -27,6 +27,7 @@ import { useColors } from '../lib/theme';
import { useLanguageStore } from '../stores/language'; import { useLanguageStore } from '../stores/language';
import { useAppLockStore } from '../stores/appLock'; import { useAppLockStore } from '../stores/appLock';
import { useLyraVoiceStore } from '../stores/lyraVoice'; import { useLyraVoiceStore } from '../stores/lyraVoice';
import { useChatBackgroundStore } from '../stores/chatBackground';
import { AppLockGate } from '../components/AppLockGate'; import { AppLockGate } from '../components/AppLockGate';
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet'; import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet'; import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet';
@ -65,6 +66,7 @@ function RootLayoutInner() {
const initLanguage = useLanguageStore((s) => s.init); const initLanguage = useLanguageStore((s) => s.init);
const initAppLock = useAppLockStore((s) => s.init); const initAppLock = useAppLockStore((s) => s.init);
const initLyraVoice = useLyraVoiceStore((s) => s.init); const initLyraVoice = useLyraVoiceStore((s) => s.init);
const initChatBackground = useChatBackgroundStore((s) => s.init);
const appLockReady = useAppLockStore((s) => s.ready); const appLockReady = useAppLockStore((s) => s.ready);
const initRealtimeDebug = useRealtimeDebugStore((s) => s.init); const initRealtimeDebug = useRealtimeDebugStore((s) => s.init);
const colors = useColors(); const colors = useColors();
@ -107,6 +109,7 @@ function RootLayoutInner() {
initLanguage(); initLanguage();
initAppLock(); initAppLock();
initLyraVoice(); initLyraVoice();
initChatBackground();
if (__DEV__) initRealtimeDebug(); if (__DEV__) initRealtimeDebug();
}, []); }, []);

View File

@ -36,6 +36,7 @@ import { useDmRealtime } from '../hooks/useChatRealtime';
import { useDmTyping } from '../hooks/useDmTyping'; import { useDmTyping } from '../hooks/useDmTyping';
import { useColors } from '../lib/theme'; import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme'; import { useThemeStore } from '../stores/theme';
import { useChatBackgroundStore, type ChatBgStyle } from '../stores/chatBackground';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { UserAvatar } from '../components/UserAvatar'; import { UserAvatar } from '../components/UserAvatar';
@ -94,10 +95,21 @@ export default function DmScreen() {
const myUserId = useAuthStore((s) => s.user?.id); const myUserId = useAuthStore((s) => s.user?.id);
const colorScheme = useThemeStore((s) => s.colorScheme); const colorScheme = useThemeStore((s) => s.colorScheme);
const chatBg = colorScheme === 'dark' ? '#1a1f1e' : '#EDE8E1';
const { userId } = useLocalSearchParams<{ userId: string }>(); const { userId } = useLocalSearchParams<{ userId: string }>();
// Pro-Chat-Hintergrund (lokal, gerätegebunden). Default = 'clean' (Insta-Style).
const chatBgStyle = useChatBackgroundStore((s) => (userId && s.backgrounds[userId]) || 'clean');
const setChatBg = useChatBackgroundStore((s) => s.setBackground);
// 'clean' → solider Theme-BG (weiß / schwarz). 'pattern' → WA-artiger Symbol-BG
// mit warmem/dunklem Tint.
const chatBg =
chatBgStyle === 'pattern'
? colorScheme === 'dark'
? '#1a1f1e'
: '#EDE8E1'
: colors.bg;
const flatListRef = useRef<FlatListType<ChatMsg>>(null); const flatListRef = useRef<FlatListType<ChatMsg>>(null);
// scrollToEnd() unterschätzt auf Android UND iOS die Content-Höhe und // scrollToEnd() unterschätzt auf Android UND iOS die Content-Höhe und
@ -747,7 +759,7 @@ export default function DmScreen() {
</View> </View>
<View style={{ flex: 1, backgroundColor: chatBg }}> <View style={{ flex: 1, backgroundColor: chatBg }}>
<DmChatBackground /> {chatBgStyle === 'pattern' && <DmChatBackground />}
{(isLoading || isFetching) && messages.length === 0 ? ( {(isLoading || isFetching) && messages.length === 0 ? (
<View style={styles.loadingBox}> <View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} /> <ActivityIndicator color={colors.brandOrange} />
@ -780,12 +792,13 @@ export default function DmScreen() {
paddingHorizontal: 0, paddingHorizontal: 0,
paddingTop: 12, paddingTop: 12,
// Tastatur offen: Input-Bar floatet (per transform) über der Tastatur, // Tastatur offen: Input-Bar floatet (per transform) über der Tastatur,
// der Viewport schrumpft NICHT → Clearance = keyboardHeight. // der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap).
// Tastatur zu: die Input-Bar (KeyboardStickyView) sitzt in ihrem // Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom,
// eigenen Layout-Slot UNTER der FlatList, ihre Höhe ist also schon // schiebt die Bar also um insets.bottom NACH OBEN über den Content →
// abgedeckt — hier nur ein knapper WA-Style-Gap, sonst „schwebt" die // diese Überlappung muss als Clearance abgezogen werden, sonst wird die
// letzte Nachricht beim Initial-Load zu hoch über der Bar. // letzte Nachricht halb verdeckt. insets.bottom + 4 hält denselben
paddingBottom: keyboardVisible ? keyboardHeight + 4 : 8, // knappen Gap wie im Keyboard-offen-State.
paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 4,
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"

View File

@ -0,0 +1,39 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@rebreak/chat-backgrounds';
// Pro-Chat-Hintergrund (lokal, gerätegebunden). Default = 'clean' (Insta-Style,
// solider Theme-BG ohne Muster). 'pattern' = der WhatsApp-artige SVG-Symbol-BG.
export type ChatBgStyle = 'clean' | 'pattern';
export const DEFAULT_CHAT_BG: ChatBgStyle = 'clean';
type ChatBackgroundState = {
// partnerId → Stil. Fehlt der Key → DEFAULT_CHAT_BG.
backgrounds: Record<string, ChatBgStyle>;
init: () => Promise<void>;
setBackground: (partnerId: string, style: ChatBgStyle) => Promise<void>;
};
export const useChatBackgroundStore = create<ChatBackgroundState>((set, get) => ({
backgrounds: {},
init: async () => {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) set({ backgrounds: JSON.parse(raw) });
} catch {
// non-fatal — Default-Clean greift
}
},
setBackground: async (partnerId, style) => {
const next = { ...get().backgrounds, [partnerId]: style };
set({ backgrounds: next });
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// non-fatal
}
},
}));

View File

@ -35,9 +35,12 @@ Building Release AAB (gradlew bundleRelease)|320
Validating IPA (App-Store Connect)|105 Validating IPA (App-Store Connect)|105
Uploading zu App-Store Connect (TestFlight)|117 Uploading zu App-Store Connect (TestFlight)|117
Building Release AAB (gradlew bundleRelease)|398 Building Release AAB (gradlew bundleRelease)|398
Exporting App-Store IPA|24
Validating IPA (App-Store Connect)|91 Validating IPA (App-Store Connect)|91
Uploading zu App-Store Connect (TestFlight)|110 Uploading zu App-Store Connect (TestFlight)|110
Building Release AAB (gradlew bundleRelease)|326 Building Release AAB (gradlew bundleRelease)|326
Building xcarchive|198 Building xcarchive|202
Exporting Ad-Hoc IPA|19 Exporting Ad-Hoc IPA|18
Exporting App-Store IPA|22
Validating IPA (App-Store Connect)|86
Uploading zu App-Store Connect (TestFlight)|112
Building Release AAB (gradlew bundleRelease)|272

View File

@ -1,35 +1,55 @@
import { listMagicDevices } from "../../db/devices"; import { listMagicDevices } from "../../db/devices";
import { listProtectedDevices } from "../../db/protectedDevices"; import { listProtectedDevices } from "../../db/protectedDevices";
import { usePrisma } from "../../utils/prisma";
import { requireUser } from "../../utils/auth"; import { requireUser } from "../../utils/auth";
/** /**
* GET /api/magic/devices * GET /api/magic/devices
* *
* Listet alle gesch\u00fctzten Ger\u00e4te des Users f\u00fcr den Magic-Hub. Vereinigt: * Vereinigt drei Quellen f\u00fcr "registriertes Ger\u00e4t":
* - Magic-Bindings (UserDevice.magicEnrolledAt) \u2014 via Magic-App registriert * - "magic" \u2192 UserDevice mit magicEnrolledAt (Magic-Mac-App)
* - ProtectedDevices \u2014 alter Native-App-DNS-Schutz-Flow (Multi-Device) * - "locked" \u2192 UserDevice mit boundToPlan (Native-App Device-Lock, z.B. iPhone/iPad)
* - "protected" \u2192 ProtectedDevice (alter Native-App DNS-Schutz-Flow)
* *
* Response-Items haben ein `source`-Flag: * Dedupe: ProtectedDevice wird unterdr\u00fcckt wenn bereits ein UserDevice
* "magic" \u2192 voll verwaltet, unterst\u00fctzt request-release * mit \u00e4hnlichem Namen + gleicher Plattform existiert (verhindert MacBook-Doppel).
* "protected" \u2192 alter Flow, nur Anzeige + revoke (TODO: own action)
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = await requireUser(event); const user = await requireUser(event);
const db = usePrisma();
const [magic, protectedDevices] = await Promise.all([ const [magic, lockedDevices, protectedDevices] = await Promise.all([
listMagicDevices(user.id), listMagicDevices(user.id),
db.userDevice.findMany({
where: {
userId: user.id,
// Alle bound-Devices (Pro/Legend-Lock). Magic-only rows kommen
// \u00fcber `magic` rein \u2014 wir wollen hier die nicht-magic Lock-Bindings.
boundToPlan: { not: null },
magicEnrolledAt: null,
},
orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }],
select: {
id: true,
deviceId: true,
platform: true,
model: true,
name: true,
osVersion: true,
lastSeenAt: true,
releaseRequestedAt: true,
},
}),
listProtectedDevices(user.id), listProtectedDevices(user.id),
]); ]);
const magicItems = magic.map((d) => { const magicItems = magic.map((d) => {
let releaseAvailableAt: string | null = null; let releaseAvailableAt: string | null = null;
if (d.releaseRequestedAt) { if (d.releaseRequestedAt) {
const availableAt = new Date( releaseAvailableAt = new Date(
d.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000, d.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
); ).toISOString();
releaseAvailableAt = availableAt.toISOString();
} }
return { return {
source: "magic" as const, source: "magic" as const,
deviceId: d.deviceId, deviceId: d.deviceId,
@ -42,20 +62,84 @@ export default defineEventHandler(async (event) => {
}; };
}); });
const protectedItems = protectedDevices.map((d) => ({ const lockedItems = lockedDevices.map((d) => {
source: "protected" as const, let releaseAvailableAt: string | null = null;
deviceId: d.id, if (d.releaseRequestedAt) {
hostname: d.label, releaseAvailableAt = new Date(
model: d.platform, d.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
osVersion: null as string | null, ).toISOString();
magicEnrolledAt: (d.installedAt ?? d.createdAt).toISOString(), }
releaseRequestedAt: null as string | null, return {
releaseAvailableAt: null as string | null, source: "locked" as const,
deviceId: d.deviceId,
hostname: d.name ?? d.model ?? prettyPlatform(d.platform),
model: d.model,
osVersion: d.osVersion,
magicEnrolledAt: d.lastSeenAt.toISOString(),
releaseRequestedAt: d.releaseRequestedAt?.toISOString() ?? null,
releaseAvailableAt,
};
});
// Dedupe Helper \u2014 normalisiere platform + name f\u00fcr Vergleich
const norm = (s: string | null | undefined) =>
(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const platformKey = (p: string | null | undefined) => {
const n = norm(p);
if (n.startsWith("mac") || n === "darwin") return "mac";
if (n.startsWith("ios") || n.startsWith("ipad") || n.startsWith("iphone"))
return "ios";
if (n.startsWith("android")) return "android";
if (n.startsWith("windows") || n === "win") return "win";
return n;
};
const alreadyListed = [...magicItems, ...lockedItems].map((d) => ({
pk: platformKey(d.model ?? d.hostname),
nameNorm: norm(d.hostname),
})); }));
// Magic-Bindings zuerst (neuste), dann alte ProtectedDevices const protectedItems = protectedDevices
.filter((pd) => {
const pk = platformKey(pd.platform);
const labelNorm = norm(pd.label);
const dup = alreadyListed.some((u) => {
if (u.pk !== pk) return false;
if (!u.nameNorm || !labelNorm) return u.pk === pk;
return (
u.nameNorm.includes(labelNorm) || labelNorm.includes(u.nameNorm)
);
});
return !dup;
})
.map((d) => ({
source: "protected" as const,
deviceId: d.id,
hostname: d.label,
model: d.platform,
osVersion: null as string | null,
magicEnrolledAt: (d.installedAt ?? d.createdAt).toISOString(),
releaseRequestedAt: null as string | null,
releaseAvailableAt: null as string | null,
}));
return { return {
success: true, success: true,
data: [...magicItems, ...protectedItems], data: [...magicItems, ...lockedItems, ...protectedItems],
}; };
}); });
function prettyPlatform(p: string): string {
switch (p.toLowerCase()) {
case "ios":
return "iPhone / iPad";
case "android":
return "Android-Ger\u00e4t";
case "mac":
case "macos":
case "darwin":
return "Mac";
default:
return p;
}
}

View File

@ -0,0 +1,27 @@
import { usePrisma } from "../../utils/prisma";
import { requireUser } from "../../utils/auth";
/**
* GET /api/magic/me
*
* Profil-Info des eingeloggten Magic-Users f\u00fcr den Hub-Header.
* Response: { nickname, avatar, plan }
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const db = usePrisma();
const profile = await db.profile.findUnique({
where: { id: user.id },
select: { nickname: true, username: true, avatar: true, plan: true },
});
return {
success: true,
data: {
nickname: profile?.nickname ?? profile?.username ?? null,
avatar: profile?.avatar ?? null,
plan: profile?.plan ?? null,
},
};
});