✨ Shuffle post iOS widget
This commit is contained in:
549
ios/SolianWidgetExtension/SolianPostShuffleWidget.swift
Normal file
549
ios/SolianWidgetExtension/SolianPostShuffleWidget.swift
Normal file
@@ -0,0 +1,549 @@
|
||||
//
|
||||
// SolianPostShuffleWidget.swift
|
||||
// SolianWidgetExtension
|
||||
//
|
||||
// Created by LittleSheep on 2026/1/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct SnPostPublisher: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let nick: String?
|
||||
let description: String?
|
||||
let picture: SnCloudFile?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case nick
|
||||
case description
|
||||
case picture
|
||||
}
|
||||
}
|
||||
|
||||
struct SnCloudFile: Codable {
|
||||
let id: String
|
||||
let url: String?
|
||||
let thumbnail: String?
|
||||
let mimeType: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
case thumbnail
|
||||
case mimeType = "mimetype"
|
||||
}
|
||||
}
|
||||
|
||||
struct SnPost: Codable, Identifiable {
|
||||
let id: String
|
||||
let title: String?
|
||||
let description: String?
|
||||
let content: String?
|
||||
let publisher: SnPostPublisher
|
||||
let tags: [SnPostTag]?
|
||||
let createdAt: String?
|
||||
let updatedAt: String?
|
||||
let attachments: [SnCloudFile]?
|
||||
|
||||
var createdDate: Date? {
|
||||
guard let createdAt = createdAt else { return nil }
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
if let date = formatter.date(from: createdAt) {
|
||||
return date
|
||||
}
|
||||
|
||||
// Fallback for timestamps without fractional seconds
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: createdAt)
|
||||
}
|
||||
|
||||
var hasTitle: Bool {
|
||||
guard let title = title else { return false }
|
||||
return !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var hasDescription: Bool {
|
||||
guard let description = description else { return false }
|
||||
return !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var hasContent: Bool {
|
||||
guard let content = content else { return false }
|
||||
return !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var attachmentCount: Int {
|
||||
attachments?.count ?? 0
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case description
|
||||
case content
|
||||
case publisher
|
||||
case tags
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case attachments
|
||||
}
|
||||
}
|
||||
|
||||
struct SnPostTag: Codable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let name: String?
|
||||
}
|
||||
|
||||
class PostShuffleService {
|
||||
private let networkService = WidgetNetworkService()
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 10.0
|
||||
configuration.timeoutIntervalForResource = 10.0
|
||||
configuration.waitsForConnectivity = false
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
func fetchRandomPost() async throws -> SnPost? {
|
||||
guard let token = networkService.token else {
|
||||
throw RemoteError.missingCredentials
|
||||
}
|
||||
|
||||
let baseURL = networkService.baseURL
|
||||
guard let url = URL(string: "\(baseURL)/sphere/posts?shuffle=true&take=1") else {
|
||||
throw RemoteError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.timeoutInterval = 10.0
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw RemoteError.invalidResponse
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200...299:
|
||||
let decoder = JSONDecoder()
|
||||
let posts = try decoder.decode([SnPost].self, from: data)
|
||||
return posts.first
|
||||
case 404:
|
||||
return nil
|
||||
default:
|
||||
throw RemoteError.httpError(httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PostShuffleEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let post: SnPost?
|
||||
let error: String?
|
||||
let isLoading: Bool
|
||||
|
||||
static func placeholder() -> PostShuffleEntry {
|
||||
PostShuffleEntry(date: Date(), post: nil, error: nil, isLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct PostShuffleProvider: TimelineProvider {
|
||||
private let postShuffleService = PostShuffleService()
|
||||
|
||||
func placeholder(in context: Context) -> PostShuffleEntry {
|
||||
PostShuffleEntry.placeholder()
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (PostShuffleEntry) -> ()) {
|
||||
Task {
|
||||
print("[WidgetKit] [PostShuffleProvider] Getting snapshot...")
|
||||
let post = try? await postShuffleService.fetchRandomPost()
|
||||
|
||||
print("[WidgetKit] [PostShuffleProvider] Snapshot - Post: \(post != nil ? "Found" : "Not found")")
|
||||
|
||||
let entry = PostShuffleEntry(date: Date(), post: post, error: nil, isLoading: false)
|
||||
completion(entry)
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
|
||||
Task {
|
||||
let currentDate = Date()
|
||||
print("[WidgetKit] [PostShuffleProvider] Getting timeline at \(currentDate)...")
|
||||
|
||||
do {
|
||||
let post = try await postShuffleService.fetchRandomPost()
|
||||
|
||||
print("[WidgetKit] [PostShuffleProvider] Timeline - Post: \(post != nil ? "Found" : "Not found")")
|
||||
|
||||
let entry = PostShuffleEntry(date: currentDate, post: post, error: nil, isLoading: false)
|
||||
|
||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 10, to: currentDate)!
|
||||
print("[WidgetKit] [PostShuffleProvider] Next update at: \(nextUpdate)")
|
||||
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
||||
completion(timeline)
|
||||
} catch {
|
||||
print("[WidgetKit] [PostShuffleProvider] Error in getTimeline: \(error.localizedDescription)")
|
||||
let entry = PostShuffleEntry(date: currentDate, post: nil, error: error.localizedDescription, isLoading: false)
|
||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!
|
||||
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
||||
completion(timeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PostShuffleWidgetEntryView: View {
|
||||
var entry: PostShuffleProvider.Entry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
var body: some View {
|
||||
if let post = entry.post {
|
||||
PostContentView(post: post)
|
||||
} else if entry.isLoading {
|
||||
LoadingView()
|
||||
} else if let error = entry.error {
|
||||
ErrorView(error: error)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func PostContentView(post: SnPost) -> some View {
|
||||
Link(destination: URL(string: "solian://posts/\(post.id)")!) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if let avatarUrl = post.publisher.picture?.url ?? post.publisher.picture?.thumbnail {
|
||||
AsyncImage(url: URL(string: avatarUrl)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .failure(_):
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
default:
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.overlay(
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(post.publisher.nick ?? post.publisher.name)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
|
||||
if let createdDate = post.createdDate {
|
||||
Text(formatRelativeTime(createdDate))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if post.attachmentCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(post.attachmentCount)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if post.hasTitle {
|
||||
Text(post.title!)
|
||||
.font(family == .systemMedium ? .subheadline : .body)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(family == .systemMedium ? 2 : 3)
|
||||
}
|
||||
|
||||
if post.hasDescription {
|
||||
Text(post.description!)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(family == .systemMedium ? 2 : 4)
|
||||
}
|
||||
|
||||
if post.hasContent {
|
||||
Text(post.content!)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(family == .systemMedium ? 3 : 8)
|
||||
}
|
||||
|
||||
if let tags = post.tags, !tags.isEmpty {
|
||||
let displayTags = Array(tags.prefix(family == .systemMedium ? 2 : 4))
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(displayTags, id: \.id) { tag in
|
||||
Text("#\(tag.name ?? tag.slug)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.blue)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func EmptyView() -> some View {
|
||||
Link(destination: URL(string: "solian://posts/shuffle")!) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "text.alignleft")
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(NSLocalizedString("noPostsAvailable", comment: "No posts available"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(NSLocalizedString("tapToRefresh", comment: "Tap to refresh"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func LoadingView() -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text(NSLocalizedString("loadingPost", comment: "Loading post..."))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ErrorView(error: String) -> some View {
|
||||
Link(destination: URL(string: "solian://posts/shuffle")!) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(NSLocalizedString("error", comment: "Error"))
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(NSLocalizedString("openAppToRefresh", comment: "Open app to refresh"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(5)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatRelativeTime(_ date: Date) -> String {
|
||||
let now = Date()
|
||||
let interval = now.timeIntervalSince(date)
|
||||
|
||||
if interval < 60 {
|
||||
return NSLocalizedString("justNow", comment: "Just now")
|
||||
} else if interval < 3600 {
|
||||
let minutes = Int(interval / 60)
|
||||
return String(format: NSLocalizedString("minutesAgo", comment: "%d min ago"), minutes)
|
||||
} else if interval < 86400 {
|
||||
let hours = Int(interval / 3600)
|
||||
return String(format: NSLocalizedString("hoursAgo", comment: "%d hr ago"), hours)
|
||||
} else {
|
||||
let days = Int(interval / 86400)
|
||||
return String(format: NSLocalizedString("daysAgo", comment: "%d d ago"), days)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PostShuffleWidgetRootView: View {
|
||||
var entry: PostShuffleProvider.Entry
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
ZStack {
|
||||
PostShuffleWidgetEntryView(entry: entry)
|
||||
|
||||
if entry.post != nil {
|
||||
GeometryReader { geometry in
|
||||
Image(colorScheme == .dark ? "CloudyLambDark" : "CloudyLamb")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(
|
||||
width: geometry.size.width * 0.9,
|
||||
height: geometry.size.width * 0.9
|
||||
)
|
||||
.opacity(0.12)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.white,
|
||||
Color.white,
|
||||
Color.clear
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.position(
|
||||
x: geometry.size.width * 0.9,
|
||||
y: 20
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
PostShuffleWidgetEntryView(entry: entry)
|
||||
.padding()
|
||||
.background()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SolianPostShuffleWidget: Widget {
|
||||
let kind: String = "SolianPostShuffleWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: PostShuffleProvider()) { entry in
|
||||
PostShuffleWidgetRootView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Random Post")
|
||||
.description("Discover a random post from the network")
|
||||
.supportedFamilies(supportedFamilies)
|
||||
}
|
||||
|
||||
private var supportedFamilies: [WidgetFamily] {
|
||||
return [.systemMedium, .systemLarge]
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(as: .systemMedium) {
|
||||
SolianPostShuffleWidget()
|
||||
} timeline: {
|
||||
PostShuffleEntry(
|
||||
date: .now,
|
||||
post: SnPost(
|
||||
id: "test-post-id",
|
||||
title: "Hello World!",
|
||||
description: "This is a test post description",
|
||||
content: "This is a content of a test post. It can be longer and show more text in the widget.",
|
||||
publisher: SnPostPublisher(
|
||||
id: "publisher-1",
|
||||
name: "Test Publisher",
|
||||
nick: "Testy",
|
||||
description: "A test publisher",
|
||||
picture: nil
|
||||
),
|
||||
tags: [
|
||||
SnPostTag(id: "tag-1", slug: "test", name: "Test"),
|
||||
SnPostTag(id: "tag-2", slug: "example", name: "Example")
|
||||
],
|
||||
createdAt: ISO8601DateFormatter().string(from: Date()),
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date()),
|
||||
attachments: []
|
||||
),
|
||||
error: nil,
|
||||
isLoading: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview(as: .systemLarge) {
|
||||
SolianPostShuffleWidget()
|
||||
} timeline: {
|
||||
PostShuffleEntry(
|
||||
date: .now,
|
||||
post: SnPost(
|
||||
id: "test-post-id",
|
||||
title: "Welcome to the Solar Network!",
|
||||
description: "This is a test post description that is a bit longer to demonstrate the widget layout with various content types",
|
||||
content: "This is content of a test post. It can be longer and show more text in the widget. The large widget should display more content and allow for better reading experience. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
publisher: SnPostPublisher(
|
||||
id: "publisher-1",
|
||||
name: "Test Publisher",
|
||||
nick: "Testy McTestface",
|
||||
description: "A test publisher",
|
||||
picture: nil
|
||||
),
|
||||
tags: [
|
||||
SnPostTag(id: "tag-1", slug: "test", name: "Test"),
|
||||
SnPostTag(id: "tag-2", slug: "example", name: "Example"),
|
||||
SnPostTag(id: "tag-3", slug: "widget", name: "Widget"),
|
||||
SnPostTag(id: "tag-4", slug: "ios", name: "iOS")
|
||||
],
|
||||
createdAt: ISO8601DateFormatter().string(from: Date()),
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date()),
|
||||
attachments: [
|
||||
SnCloudFile(id: "file-1", url: nil, thumbnail: nil, mimeType: "image/jpeg"),
|
||||
SnCloudFile(id: "file-2", url: nil, thumbnail: nil, mimeType: "image/png")
|
||||
]
|
||||
),
|
||||
error: nil,
|
||||
isLoading: false
|
||||
)
|
||||
}
|
||||
@@ -13,5 +13,6 @@ struct SolianWidgetExtensionBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
SolianCheckInWidget()
|
||||
SolianNotificationWidget()
|
||||
SolianPostShuffleWidget()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,3 +39,8 @@
|
||||
"noNotifications" = "No notifications";
|
||||
"noUnreadNotifications" = "All notifications are read";
|
||||
"unread" = "unread";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "No posts available";
|
||||
"tapToRefresh" = "Tap to refresh";
|
||||
"loadingPost" = "Loading post...";
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
"invalidResponse" = "El servidor devolvió una respuesta no válida.";
|
||||
"httpError" = "Error del servidor (%d).";
|
||||
"decodingError" = "Error al leer los datos del servidor.";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "No hay publicaciones disponibles";
|
||||
"tapToRefresh" = "Toca para actualizar";
|
||||
"loadingPost" = "Cargando publicación...";
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
"invalidResponse" = "サーバーから無効な応答が返されました。";
|
||||
"httpError" = "サーバーエラー (%d)。";
|
||||
"decodingError" = "サーバーデータの読み込みに失敗しました。";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "利用可能な投稿がありません";
|
||||
"tapToRefresh" = "タップして更新";
|
||||
"loadingPost" = "投稿を読み込み中...";
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
"invalidResponse" = "서버에서 잘못된 응답을 받았습니다.";
|
||||
"httpError" = "서버 오류 (%d).";
|
||||
"decodingError" = "서버 데이터 읽기에 실패했습니다.";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "사용 가능한 게시물이 없습니다";
|
||||
"tapToRefresh" = "탭하여 새로고침";
|
||||
"loadingPost" = "게시물 로딩 중...";
|
||||
|
||||
@@ -39,3 +39,8 @@
|
||||
"noNotifications" = "没有通知";
|
||||
"noUnreadNotifications" = "没有未读通知";
|
||||
"unread" = "未读";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "没有可用的帖子";
|
||||
"tapToRefresh" = "点击刷新";
|
||||
"loadingPost" = "加载帖子中...";
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
"invalidResponse" = "伺服器返回了無效響應。";
|
||||
"httpError" = "伺服器錯誤 (%d)。";
|
||||
"decodingError" = "讀取伺服器數據失敗。";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "沒有可用的帖子";
|
||||
"tapToRefresh" = "點擊刷新";
|
||||
"loadingPost" = "載入帖子中...";
|
||||
|
||||
Reference in New Issue
Block a user