Initial commit: nightly iOS app + Supabase backend
iOS SwiftUI app with Supabase auth/realtime, Node.js backend, Docker/Supabase self-hosted infrastructure, and APNs scheduler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
actor APIService {
|
||||
static let shared = APIService()
|
||||
|
||||
// Change to your server URL
|
||||
private let baseURL = URL(string: "https://api.nightly.app/")!
|
||||
|
||||
private var authToken: String? { KeychainService.shared.getToken() }
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
func login(username: String, password: String) async throws {
|
||||
let r: AuthResponse = try await post("auth/login", body: [
|
||||
"username": username, "password": password
|
||||
])
|
||||
KeychainService.shared.saveToken(r.token)
|
||||
}
|
||||
|
||||
func register(username: String, password: String, displayName: String) async throws {
|
||||
let r: AuthResponse = try await post("auth/register", body: [
|
||||
"username": username, "password": password, "displayName": displayName
|
||||
])
|
||||
KeychainService.shared.saveToken(r.token)
|
||||
}
|
||||
|
||||
func getCurrentUser() async throws -> User {
|
||||
try await get("users/me")
|
||||
}
|
||||
|
||||
// MARK: - Posts
|
||||
|
||||
func getFeed() async throws -> [Post] {
|
||||
try await get("posts/feed")
|
||||
}
|
||||
|
||||
func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws {
|
||||
let _: EmptyResponse = try await post("posts", body: [
|
||||
"content": content,
|
||||
"mood": mood.rawValue,
|
||||
"isAnonymous": isAnonymous
|
||||
])
|
||||
}
|
||||
|
||||
func resonate(postId: String) async throws {
|
||||
let _: EmptyResponse = try await post("posts/\(postId)/resonate", body: [:])
|
||||
}
|
||||
|
||||
func unresoante(postId: String) async throws {
|
||||
let _: EmptyResponse = try await delete("posts/\(postId)/resonate")
|
||||
}
|
||||
|
||||
func sendWhisper(toUserId: String, content: String) async throws {
|
||||
let _: EmptyResponse = try await post("users/\(toUserId)/whisper", body: ["content": content])
|
||||
}
|
||||
|
||||
// MARK: - Users
|
||||
|
||||
func getUserPosts(userId: String) async throws -> [Post] {
|
||||
try await get("users/\(userId)/posts")
|
||||
}
|
||||
|
||||
func getUserStreak(userId: String) async throws -> Int {
|
||||
let r: StreakResponse = try await get("users/\(userId)/streak")
|
||||
return r.streak
|
||||
}
|
||||
|
||||
func registerPushToken(_ token: String) async {
|
||||
_ = try? await post("users/me/push-token", body: ["token": token]) as EmptyResponse
|
||||
}
|
||||
|
||||
// MARK: - HTTP
|
||||
|
||||
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
try await perform(makeRequest("GET", path: path))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func post<T: Decodable>(_ path: String, body: [String: Any]) async throws -> T {
|
||||
var req = makeRequest("POST", path: path)
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
return try await perform(req)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func delete<T: Decodable>(_ path: String) async throws -> T {
|
||||
try await perform(makeRequest("DELETE", path: path))
|
||||
}
|
||||
|
||||
private func makeRequest(_ method: String, path: String) -> URLRequest {
|
||||
var req = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
if let t = authToken { req.setValue("Bearer \(t)", forHTTPHeaderField: "Authorization") }
|
||||
return req
|
||||
}
|
||||
|
||||
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
let msg = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.message
|
||||
?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
|
||||
throw APIError.serverError(msg)
|
||||
}
|
||||
let dec = JSONDecoder()
|
||||
dec.dateDecodingStrategy = .iso8601
|
||||
return try dec.decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AuthResponse: Decodable { let token: String }
|
||||
private struct StreakResponse: Decodable { let streak: Int }
|
||||
private struct APIErrorBody: Decodable { let message: String }
|
||||
struct EmptyResponse: Decodable {}
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidResponse
|
||||
case serverError(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: return "Ungültige Serverantwort"
|
||||
case .serverError(let m): return m
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
final class KeychainService {
|
||||
static let shared = KeychainService()
|
||||
private let service = "app.nightly"
|
||||
private let account = "authToken"
|
||||
|
||||
func saveToken(_ token: String) {
|
||||
let data = Data(token.utf8)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecValueData: data
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func getToken() -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func deleteToken() {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: account
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
final class NotificationService {
|
||||
static let shared = NotificationService()
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
(try? await UNUserNotificationCenter.current()
|
||||
.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func registerForRemoteNotifications() {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Supabase
|
||||
|
||||
/// Verwaltet die Echtzeit-Verbindung für "Gerade Jetzt".
|
||||
/// Neue Posts erscheinen sofort ohne Polling.
|
||||
@MainActor
|
||||
class RealtimeService: ObservableObject {
|
||||
@Published var newPostsCount = 0
|
||||
|
||||
private var channel: RealtimeChannelV2?
|
||||
private var onNewPost: ((Post) -> Void)?
|
||||
|
||||
func startListening(onNewPost: @escaping (Post) -> Void) async {
|
||||
self.onNewPost = onNewPost
|
||||
guard channel == nil else { return }
|
||||
|
||||
let ch = await supabase.channel("public:posts")
|
||||
|
||||
// Neue Posts in Echtzeit empfangen
|
||||
let stream = await ch.postgresChange(
|
||||
InsertAction.self,
|
||||
schema: "public",
|
||||
table: "posts"
|
||||
)
|
||||
|
||||
await ch.subscribe()
|
||||
self.channel = ch
|
||||
|
||||
// Stream im Hintergrund konsumieren
|
||||
Task { [weak self] in
|
||||
for await action in stream {
|
||||
await self?.handleInsert(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopListening() async {
|
||||
if let ch = channel {
|
||||
await supabase.removeChannel(ch)
|
||||
channel = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleInsert(_ action: InsertAction) {
|
||||
// Den neuen Post aus dem Record dekodieren
|
||||
guard
|
||||
let id = action.record["id"]?.stringValue,
|
||||
let content = action.record["content"]?.stringValue,
|
||||
let createdAt = action.record["created_at"]?.stringValue
|
||||
.flatMap({ ISO8601DateFormatter().date(from: $0) }),
|
||||
let userId = action.record["user_id"]?.stringValue,
|
||||
let isAnon = action.record["is_anonymous"]?.boolValue
|
||||
else { return }
|
||||
|
||||
let moodString = action.record["mood"]?.stringValue
|
||||
let mood = moodString.flatMap(Mood.init(rawValue:))
|
||||
|
||||
let post = Post(
|
||||
id: id,
|
||||
author: User.anonymousPlaceholder, // Profil wird lazily nachgeladen
|
||||
content: content,
|
||||
mood: mood,
|
||||
createdAt: createdAt,
|
||||
resonanceCount: 0,
|
||||
hasResonated: false,
|
||||
commentCount: 0,
|
||||
isAnonymous: isAnon,
|
||||
nightOf: createdAt
|
||||
)
|
||||
|
||||
newPostsCount += 1
|
||||
onNewPost?(post)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User placeholder für Realtime (Profil wird nachgeladen)
|
||||
|
||||
extension User {
|
||||
static let anonymousPlaceholder = User(
|
||||
id: "anonymous",
|
||||
username: "anonym",
|
||||
displayName: "anonym",
|
||||
bio: nil, avatarURL: nil,
|
||||
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import Foundation
|
||||
import Supabase
|
||||
|
||||
// MARK: - Supabase Client (Singleton)
|
||||
|
||||
let supabase = SupabaseClient(
|
||||
supabaseURL: Config.supabaseURL,
|
||||
supabaseKey: Config.supabaseAnonKey,
|
||||
options: SupabaseClientOptions(
|
||||
db: .init(
|
||||
encoder: {
|
||||
let e = JSONEncoder()
|
||||
e.keyEncodingStrategy = .convertToSnakeCase
|
||||
e.dateEncodingStrategy = .iso8601
|
||||
return e
|
||||
}(),
|
||||
decoder: {
|
||||
let d = JSONDecoder()
|
||||
d.keyDecodingStrategy = .convertFromSnakeCase
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
extension SupabaseClient {
|
||||
|
||||
func signUp(email: String, password: String, username: String, displayName: String) async throws {
|
||||
try await self.auth.signUp(
|
||||
email: email,
|
||||
password: password,
|
||||
data: [
|
||||
"username": .string(username.lowercased()),
|
||||
"display_name": .string(displayName)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(email: String, password: String) async throws {
|
||||
try await self.auth.signIn(email: email, password: password)
|
||||
}
|
||||
|
||||
/// Login mit Username: holt zuerst die E-Mail, dann normaler Sign-In
|
||||
func signIn(username: String, password: String) async throws {
|
||||
let email: String? = try await self
|
||||
.rpc("get_email_by_username", params: ["p_username": username])
|
||||
.execute()
|
||||
.value
|
||||
guard let email else { throw AuthError.usernameNotFound }
|
||||
try await self.auth.signIn(email: email, password: password)
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
try await self.auth.signOut()
|
||||
}
|
||||
|
||||
/// Account vollständig löschen (DSGVO — löscht alles über DB-Funktion)
|
||||
func deleteAccount() async throws {
|
||||
try await self.rpc("delete_my_account").execute()
|
||||
}
|
||||
|
||||
var currentUserId: UUID? {
|
||||
try? self.auth.session.user.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profil
|
||||
|
||||
extension SupabaseClient {
|
||||
|
||||
func getMyProfile() async throws -> Profile {
|
||||
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
|
||||
return try await self
|
||||
.from("profiles")
|
||||
.select()
|
||||
.eq("id", value: uid)
|
||||
.single()
|
||||
.execute()
|
||||
.value
|
||||
}
|
||||
|
||||
func getProfile(userId: UUID) async throws -> Profile {
|
||||
try await self
|
||||
.from("profiles")
|
||||
.select()
|
||||
.eq("id", value: userId)
|
||||
.single()
|
||||
.execute()
|
||||
.value
|
||||
}
|
||||
|
||||
func updateProfile(displayName: String? = nil, bio: String? = nil) async throws {
|
||||
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
|
||||
var update: [String: String] = [:]
|
||||
if let n = displayName { update["display_name"] = n }
|
||||
if let b = bio { update["bio"] = b }
|
||||
guard !update.isEmpty else { return }
|
||||
try await self.from("profiles").update(update).eq("id", value: uid).execute()
|
||||
}
|
||||
|
||||
func savePushToken(_ token: String) async throws {
|
||||
guard let uid = currentUserId else { return }
|
||||
try await self.from("profiles")
|
||||
.update(["push_token": token])
|
||||
.eq("id", value: uid)
|
||||
.execute()
|
||||
}
|
||||
|
||||
func removePushToken() async throws {
|
||||
guard let uid = currentUserId else { return }
|
||||
try await self.from("profiles")
|
||||
.update(["push_token": nil as String?])
|
||||
.eq("id", value: uid)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Posts
|
||||
|
||||
extension SupabaseClient {
|
||||
|
||||
/// Feed: Posts der letzten 14h von gefollowten Usern + eigene
|
||||
func getFeed() async throws -> [Post] {
|
||||
guard let uid = currentUserId else { return [] }
|
||||
|
||||
// Erst die gefolgten User-IDs holen
|
||||
// Supabase gibt Objekte zurück [{following_id:"uuid"}], kein [String]
|
||||
struct FollowRow: Decodable { let followingId: String }
|
||||
let followRows: [FollowRow] = try await self
|
||||
.from("follows")
|
||||
.select("following_id")
|
||||
.eq("follower_id", value: uid)
|
||||
.execute()
|
||||
.value
|
||||
|
||||
let allIds = followRows.map(\.followingId) + [uid.uuidString]
|
||||
|
||||
let rows: [FeedPostRow] = try await self
|
||||
.from("feed_posts")
|
||||
.select()
|
||||
.in("author_id", values: allIds)
|
||||
.order("created_at", ascending: false)
|
||||
.limit(150)
|
||||
.execute()
|
||||
.value
|
||||
|
||||
// Eigene Resonances holen (RLS filtert, SDK gibt nur eigene zurück)
|
||||
let myResonances: [ResonanceRow] = (try? await self
|
||||
.from("resonances")
|
||||
.select("post_id")
|
||||
.eq("user_id", value: uid)
|
||||
.execute()
|
||||
.value) ?? []
|
||||
|
||||
let mySet = Set(myResonances.map(\.postId))
|
||||
|
||||
return rows.map { row in row.toPost(hasResonated: mySet.contains(row.id)) }
|
||||
}
|
||||
|
||||
/// Persönliches Tagebuch: alle eigenen Posts, auch gelöschte (soft)
|
||||
func getDiary() async throws -> [Post] {
|
||||
guard let uid = currentUserId else { return [] }
|
||||
let rows: [FeedPostRow] = try await self
|
||||
.from("posts")
|
||||
.select("id, content, mood, is_anonymous, created_at, resonance_count:resonances(count)")
|
||||
.eq("user_id", value: uid)
|
||||
.is("deleted_at", value: nil)
|
||||
.order("created_at", ascending: false)
|
||||
.limit(365)
|
||||
.execute()
|
||||
.value
|
||||
return rows.map { $0.toPost(hasResonated: false) }
|
||||
}
|
||||
|
||||
func getUserPosts(userId: UUID) async throws -> [Post] {
|
||||
let rows: [FeedPostRow] = try await self
|
||||
.from("feed_posts")
|
||||
.select()
|
||||
.eq("author_id", value: userId)
|
||||
.order("created_at", ascending: false)
|
||||
.limit(50)
|
||||
.execute()
|
||||
.value
|
||||
return rows.map { $0.toPost(hasResonated: false) }
|
||||
}
|
||||
|
||||
func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws {
|
||||
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
|
||||
try await self.from("posts").insert([
|
||||
"user_id": uid.uuidString,
|
||||
"content": content,
|
||||
"mood": mood.rawValue,
|
||||
"is_anonymous": isAnonymous
|
||||
]).execute()
|
||||
}
|
||||
|
||||
func softDeletePost(id: String) async throws {
|
||||
try await self.from("posts")
|
||||
.update(["deleted_at": ISO8601DateFormatter().string(from: Date())])
|
||||
.eq("id", value: id)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resonances
|
||||
|
||||
extension SupabaseClient {
|
||||
|
||||
func toggleResonance(postId: String, currentlyActive: Bool) async throws {
|
||||
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
|
||||
if currentlyActive {
|
||||
try await self.from("resonances")
|
||||
.delete()
|
||||
.eq("post_id", value: postId)
|
||||
.eq("user_id", value: uid)
|
||||
.execute()
|
||||
} else {
|
||||
try await self.from("resonances")
|
||||
.insert(["post_id": postId, "user_id": uid.uuidString])
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Follows
|
||||
|
||||
extension SupabaseClient {
|
||||
|
||||
func follow(userId: UUID) async throws {
|
||||
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
|
||||
try await self.from("follows")
|
||||
.insert(["follower_id": uid.uuidString, "following_id": userId.uuidString])
|
||||
.execute()
|
||||
}
|
||||
|
||||
func unfollow(userId: UUID) async throws {
|
||||
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
|
||||
try await self.from("follows")
|
||||
.delete()
|
||||
.eq("follower_id", value: uid)
|
||||
.eq("following_id", value: userId)
|
||||
.execute()
|
||||
}
|
||||
|
||||
func getStreak(userId: UUID) async throws -> Int {
|
||||
// Nächte mit Posts — berechnet in SQL
|
||||
let rows: [[String: Int]] = (try? await self
|
||||
.rpc("get_streak", params: ["p_user_id": userId.uuidString])
|
||||
.execute()
|
||||
.value) ?? []
|
||||
return rows.first?["streak"] ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reports
|
||||
|
||||
extension SupabaseClient {
|
||||
|
||||
func reportPost(postId: String, reason: String, details: String?) async throws {
|
||||
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
|
||||
try await self.from("reports").insert([
|
||||
"post_id": postId,
|
||||
"reporter_id": uid.uuidString,
|
||||
"reason": reason,
|
||||
"details": details as Any
|
||||
]).execute()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Whispers
|
||||
|
||||
extension SupabaseClient {
|
||||
|
||||
func sendWhisper(toUserId: UUID, content: String, postId: String?) async throws {
|
||||
guard let uid = currentUserId else { throw AuthError.notAuthenticated }
|
||||
try await self.from("whispers").insert([
|
||||
"from_user_id": uid.uuidString,
|
||||
"to_user_id": toUserId.uuidString,
|
||||
"content": content,
|
||||
"post_id": postId as Any
|
||||
]).execute()
|
||||
}
|
||||
|
||||
func getMyWhispers() async throws -> [Whisper] {
|
||||
guard let uid = currentUserId else { return [] }
|
||||
return try await self
|
||||
.from("whispers")
|
||||
.select("*, from_profile:profiles!from_user_id(username, display_name, avatar_url)")
|
||||
.eq("to_user_id", value: uid)
|
||||
.order("created_at", ascending: false)
|
||||
.limit(50)
|
||||
.execute()
|
||||
.value
|
||||
}
|
||||
|
||||
func markWhisperRead(id: UUID) async throws {
|
||||
try await self.from("whispers")
|
||||
.update(["read_at": ISO8601DateFormatter().string(from: Date())])
|
||||
.eq("id", value: id)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum AuthError: LocalizedError {
|
||||
case notAuthenticated
|
||||
case usernameNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthenticated: return "Nicht angemeldet"
|
||||
case .usernameNotFound: return "Benutzername nicht gefunden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row types (Supabase responses)
|
||||
|
||||
struct FeedPostRow: Decodable {
|
||||
let id: String
|
||||
let content: String
|
||||
let mood: String?
|
||||
let isAnonymous: Bool
|
||||
let createdAt: Date
|
||||
let resonanceCount: Int
|
||||
// Autor (nil bei anonymen Posts die nicht von mir sind)
|
||||
let authorId: String?
|
||||
let authorUsername: String?
|
||||
let authorDisplayName: String?
|
||||
let authorAvatarUrl: String?
|
||||
|
||||
func toPost(hasResonated: Bool) -> Post {
|
||||
let author: User? = authorId.map {
|
||||
User(
|
||||
id: $0,
|
||||
username: authorUsername ?? "?",
|
||||
displayName: authorDisplayName ?? "?",
|
||||
bio: nil, avatarURL: authorAvatarUrl.flatMap(URL.init),
|
||||
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
|
||||
)
|
||||
}
|
||||
return Post(
|
||||
id: id,
|
||||
author: author ?? User.anonymousPlaceholder,
|
||||
content: content,
|
||||
mood: mood.flatMap(Mood.init(rawValue:)),
|
||||
createdAt: createdAt,
|
||||
resonanceCount: resonanceCount,
|
||||
hasResonated: hasResonated,
|
||||
commentCount: 0,
|
||||
isAnonymous: isAnonymous,
|
||||
nightOf: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ResonanceRow: Decodable { let postId: String }
|
||||
struct Profile: Decodable {
|
||||
let id: UUID
|
||||
let username: String
|
||||
let displayName: String
|
||||
let bio: String?
|
||||
let avatarUrl: String?
|
||||
let isPro: Bool
|
||||
let isAdmin: Bool
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
struct Whisper: Identifiable, Decodable {
|
||||
let id: UUID
|
||||
let fromUserId: UUID
|
||||
let content: String
|
||||
let readAt: Date?
|
||||
let createdAt: Date
|
||||
}
|
||||
Reference in New Issue
Block a user