feat: modern macOS window chrome + Linux build script
macOS: - Transparent title bar with hidden title, full-size content view - macOS: inline Row header (no AppBar) with 56px traffic-light pad - Other platforms: standard Material AppBar unchanged - App name 'Chanora' in CFBundleName/CFBundleDisplayName (iOS + macOS) - NSLocalNetworkUsageDescription added to both platforms Linux: - tools/build-linux.sh: builds Rust .so + Flutter bundle + tarball - Verifies GTK3, libopus dev headers, Rust target - Copies libchanora_bridge.so into bundle/lib/
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Chanora Flutter</string>
|
<string>Chanora</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>chanora_flutter</string>
|
<string>Chanora</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import 'widgets/voice_bar.dart';
|
|||||||
import 'widgets/voice_compact.dart';
|
import 'widgets/voice_compact.dart';
|
||||||
import 'widgets/voice_settings.dart';
|
import 'widgets/voice_settings.dart';
|
||||||
|
|
||||||
|
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
||||||
|
|
||||||
|
/// Top padding for macOS to clear traffic-light buttons.
|
||||||
|
const double _macOSTrafficLightPad = 56.0;
|
||||||
|
|
||||||
/// True when the host is a touch-only mobile platform without a
|
/// True when the host is a touch-only mobile platform without a
|
||||||
/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
|
/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
|
||||||
/// and widgets/voice_settings.dart so the AppBar + narrow-mode
|
/// and widgets/voice_settings.dart so the AppBar + narrow-mode
|
||||||
@@ -1007,27 +1012,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
final headerActions = [
|
||||||
appBar: AppBar(
|
|
||||||
title: _AppBarTitle(
|
|
||||||
phase: _phase,
|
|
||||||
inChannel: _inChannel,
|
|
||||||
channelName: _currentVoiceChannelName(),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
// Narrow-mode AppBar gains mic-mute + headset-mute icons
|
|
||||||
// when the user is in a voice channel, so the on-screen
|
|
||||||
// body can dedicate ~85% of its height to the channel tree
|
|
||||||
// + the bottom-anchored PTT button. Wide mode keeps these
|
|
||||||
// controls inside the VoiceBar widget (left column) so the
|
|
||||||
// signed-off rc.8 wide-layout doesn't shift.
|
|
||||||
//
|
|
||||||
// The gear icon (Icons.tune) that used to live here was
|
|
||||||
// **removed** in the rc.8 follow-up: it duplicated the
|
|
||||||
// "Adjust mode & release tail" button now inside the modal
|
|
||||||
// sheet, and the user reported the duplication. The modal
|
|
||||||
// is reached by tapping the status chip above the PTT
|
|
||||||
// button — there is now exactly one entry point.
|
|
||||||
if (_phase == _Phase.connected &&
|
if (_phase == _Phase.connected &&
|
||||||
_inChannel &&
|
_inChannel &&
|
||||||
MediaQuery.of(context).size.width < 840.0) ...[
|
MediaQuery.of(context).size.width < 840.0) ...[
|
||||||
@@ -1052,31 +1037,22 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
onPressed: () => _onShowAbout(context),
|
onPressed: () => _onShowAbout(context),
|
||||||
),
|
),
|
||||||
if (_phase == _Phase.connected) ...[
|
if (_phase == _Phase.connected) ...[
|
||||||
// The previous Refresh action (Icons.refresh + _onRefresh)
|
|
||||||
// was removed in v1.0.0-rc.8 — the snapshot stream the
|
|
||||||
// bridge pushes via BridgeEvent::SnapshotChanged keeps the
|
|
||||||
// tree current automatically, and a manual refresh was a
|
|
||||||
// no-op from the user's perspective.
|
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.disconnectAction,
|
tooltip: l10n.disconnectAction,
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
onPressed: _onDisconnect,
|
onPressed: _onDisconnect,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
];
|
||||||
),
|
|
||||||
body: Padding(
|
final headerTitle = _AppBarTitle(
|
||||||
padding: const EdgeInsets.all(16),
|
phase: _phase,
|
||||||
child: LayoutBuilder(
|
inChannel: _inChannel,
|
||||||
|
channelName: _currentVoiceChannelName(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final bodyContent = LayoutBuilder(
|
||||||
builder: (ctx, bodyConstraints) {
|
builder: (ctx, bodyConstraints) {
|
||||||
// Shared breakpoint with the inner snapshot LayoutBuilder
|
|
||||||
// below — when the UI is wide enough to split into two
|
|
||||||
// columns AND we are showing the snapshot, the
|
|
||||||
// not-production-ready banner is moved into the left
|
|
||||||
// column above the Voice Bar so it doesn't span the
|
|
||||||
// wider channel-tree area. In every other state (narrow,
|
|
||||||
// or idle / connecting at any width) the banner stays
|
|
||||||
// pinned to the top of the body.
|
|
||||||
const wideBreakpoint = 840.0;
|
const wideBreakpoint = 840.0;
|
||||||
final isWideSnapshot =
|
final isWideSnapshot =
|
||||||
bodyConstraints.maxWidth >= wideBreakpoint &&
|
bodyConstraints.maxWidth >= wideBreakpoint &&
|
||||||
@@ -1138,9 +1114,6 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
if (_phase == _Phase.idle) ...[
|
if (_phase == _Phase.idle) ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
// Dismiss keyboard when the user drags away from
|
|
||||||
// a focused field — friendlier mobile UX than
|
|
||||||
// forcing them to tap outside.
|
|
||||||
keyboardDismissBehavior:
|
keyboardDismissBehavior:
|
||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1197,14 +1170,6 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
onJoinChannel: _onJoinChannel,
|
onJoinChannel: _onJoinChannel,
|
||||||
onLeaveVoice: _onLeaveVoice,
|
onLeaveVoice: _onLeaveVoice,
|
||||||
);
|
);
|
||||||
// Responsive: at <840 dp use a stacked layout
|
|
||||||
// (Voice Bar on top, channel tree below). At
|
|
||||||
// ≥840 dp use a side-by-side layout with the
|
|
||||||
// Voice Bar pinned to 320 dp on the left and
|
|
||||||
// the channel tree expanding on the right.
|
|
||||||
// 840 dp matches Material's tablet / desktop
|
|
||||||
// breakpoint.
|
|
||||||
const wideBreakpoint = 840.0;
|
|
||||||
const voiceBarWidthWide = 320.0;
|
const voiceBarWidthWide = 320.0;
|
||||||
if (constraints.maxWidth >= wideBreakpoint) {
|
if (constraints.maxWidth >= wideBreakpoint) {
|
||||||
return Row(
|
return Row(
|
||||||
@@ -1230,23 +1195,6 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// In narrow / one-column layout the layout
|
|
||||||
// is:
|
|
||||||
//
|
|
||||||
// [ channel tree (Expanded) ]
|
|
||||||
// [ status chip (2-line live readout) ]
|
|
||||||
// [ PTT button (mobile-only,
|
|
||||||
// PTT-mode-only) ]
|
|
||||||
//
|
|
||||||
// The chip is a tap target that opens
|
|
||||||
// `showVoiceDetailsSheet` with the mic
|
|
||||||
// level meter + TX/RX counts + capability
|
|
||||||
// badge. Mode + bind key + release-tail
|
|
||||||
// live in `VoiceSettingsDialog`
|
|
||||||
// (gear icon in AppBar).
|
|
||||||
//
|
|
||||||
// Wide mode (Row branch above) is
|
|
||||||
// unchanged from rc.8.
|
|
||||||
Expanded(child: snapshotView),
|
Expanded(child: snapshotView),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
VoiceStatusChip(
|
VoiceStatusChip(
|
||||||
@@ -1257,11 +1205,6 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
isTouchOnly: _isTouchOnlyPttHost,
|
isTouchOnly: _isTouchOnlyPttHost,
|
||||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||||
),
|
),
|
||||||
// PTT button only when PTT mode is active
|
|
||||||
// AND the user is in a voice channel. In
|
|
||||||
// Continuous mode there is nothing to
|
|
||||||
// hold; the chip alone surfaces the
|
|
||||||
// "Mic on / off" state.
|
|
||||||
if (_inChannel &&
|
if (_inChannel &&
|
||||||
_transmitMode ==
|
_transmitMode ==
|
||||||
rust.BridgeTransmitMode.ptt) ...[
|
rust.BridgeTransmitMode.ptt) ...[
|
||||||
@@ -1280,8 +1223,39 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_isMacOS) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: _macOSTrafficLightPad),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: headerTitle),
|
||||||
|
...headerActions,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Expanded(child: Padding(padding: const EdgeInsets.all(16), child: bodyContent)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: headerTitle,
|
||||||
|
actions: headerActions,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: bodyContent,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>Chanora</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Chanora</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ class MainFlutterWindow: NSWindow {
|
|||||||
self.contentViewController = flutterViewController
|
self.contentViewController = flutterViewController
|
||||||
self.setFrame(windowFrame, display: true)
|
self.setFrame(windowFrame, display: true)
|
||||||
|
|
||||||
|
self.titlebarAppearsTransparent = true
|
||||||
|
self.titleVisibility = .hidden
|
||||||
|
self.styleMask.insert(.fullSizeContentView)
|
||||||
|
self.isMovableByWindowBackground = true
|
||||||
|
|
||||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|||||||
Executable
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Chanora Linux build helper.
|
||||||
|
#
|
||||||
|
# Run this on a Linux host (Ubuntu 22.04+ / Debian bookworm+ / Fedora 38+) that
|
||||||
|
# has already completed the prerequisites in §4 of docs/release/linux-build.md:
|
||||||
|
# - Rust stable with x86_64-unknown-linux-gnu target
|
||||||
|
# - Flutter 3.41.9+ stable on PATH
|
||||||
|
# - CMake 3.13+, pkg-config, GTK3 dev headers, clang
|
||||||
|
# - System libopus dev package (libopus-dev / opus-devel)
|
||||||
|
# - flutter_rust_bridge_codegen 2.12.0 (only if regenerating bindings)
|
||||||
|
#
|
||||||
|
# Usage (from the repo root ~/chanora):
|
||||||
|
# ./tools/build-linux.sh
|
||||||
|
# ./tools/build-linux.sh --no-rust # skip Rust rebuild (cached)
|
||||||
|
# ./tools/build-linux.sh --regenerate-bindings # rerun FRB codegen first
|
||||||
|
# ./tools/build-linux.sh --version v0.2.0-beta.1
|
||||||
|
#
|
||||||
|
# Produces:
|
||||||
|
# target/x86_64-unknown-linux-gnu/release/libchanora_bridge.so
|
||||||
|
# apps/chanora_flutter/build/linux/x64/release/bundle/chanora_flutter
|
||||||
|
# chanora-<version>-linux-x64.tar.gz (release bundle)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="v0.2.0-beta.1"
|
||||||
|
SKIP_RUST=0
|
||||||
|
REGEN=0
|
||||||
|
TARGET="x86_64-unknown-linux-gnu"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--version) VERSION="$2"; shift 2;;
|
||||||
|
--no-rust) SKIP_RUST=1; shift;;
|
||||||
|
--regenerate-bindings) REGEN=1; shift;;
|
||||||
|
--target) TARGET="$2"; shift 2;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 2;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
REPO_ROOT="$(pwd)"
|
||||||
|
|
||||||
|
bar() { printf '%s\n' '========================================================================'; }
|
||||||
|
|
||||||
|
bar
|
||||||
|
echo "Chanora Linux build"
|
||||||
|
echo "Repo root : $REPO_ROOT"
|
||||||
|
echo "Version : $VERSION"
|
||||||
|
echo "Target : $TARGET"
|
||||||
|
echo "Build Rust: $([ "$SKIP_RUST" = 1 ] && echo skip || echo yes)"
|
||||||
|
bar
|
||||||
|
|
||||||
|
# ---------- 1. Verify toolchain ----------
|
||||||
|
echo "[1/6] Verify toolchain"
|
||||||
|
for cmd in cargo rustc flutter cmake pkg-config clang gcc; do
|
||||||
|
if ! command -v "$cmd" >/dev/null; then
|
||||||
|
echo " ERROR: '$cmd' not on PATH. See docs/release/linux-build.md." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf " %-12s : %s\n" "$cmd" "$($cmd --version 2>&1 | head -1)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verify GTK3 dev headers
|
||||||
|
if ! pkg-config --exists gtk+-3.0 2>/dev/null; then
|
||||||
|
echo " ERROR: gtk+-3.0 dev headers not found. Install libgtk-3-dev (Debian/Ubuntu) or gtk3-devel (Fedora)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify libopus dev headers (needed by audiopus_sys to link)
|
||||||
|
if ! pkg-config --exists opus 2>/dev/null; then
|
||||||
|
echo " ERROR: opus dev headers not found. Install libopus-dev (Debian/Ubuntu) or opus-devel (Fedora)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify Rust target
|
||||||
|
if ! rustup target list --installed | grep -q "^$TARGET$"; then
|
||||||
|
echo " Adding rustup target $TARGET..."
|
||||||
|
rustup target add "$TARGET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 2. Add Linux platform if missing ----------
|
||||||
|
FLUTTER_APP="$REPO_ROOT/apps/chanora_flutter"
|
||||||
|
if [[ ! -d "$FLUTTER_APP/linux" ]]; then
|
||||||
|
echo "[2/6] Add Linux platform to apps/chanora_flutter"
|
||||||
|
(cd "$FLUTTER_APP" && flutter create --platforms=linux .)
|
||||||
|
else
|
||||||
|
echo "[2/6] Linux platform already present in apps/chanora_flutter"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 3. (Optional) regenerate FRB bindings ----------
|
||||||
|
if [[ $REGEN -eq 1 ]]; then
|
||||||
|
echo "[3/6] Regenerate flutter_rust_bridge bindings"
|
||||||
|
if ! command -v flutter_rust_bridge_codegen >/dev/null; then
|
||||||
|
echo " flutter_rust_bridge_codegen not on PATH; install it first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
flutter_rust_bridge_codegen generate
|
||||||
|
else
|
||||||
|
echo "[3/6] Skipping FRB codegen (use --regenerate-bindings if api.rs changed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 4. Build the Rust cdylib ----------
|
||||||
|
BRIDGE_SO="$REPO_ROOT/target/$TARGET/release/libchanora_bridge.so"
|
||||||
|
|
||||||
|
if [[ $SKIP_RUST -eq 1 ]]; then
|
||||||
|
echo "[4/6] Skipping Rust build (cached)"
|
||||||
|
if [[ ! -f "$BRIDGE_SO" ]]; then
|
||||||
|
echo " ERROR: --no-rust but $BRIDGE_SO not found. Run without --no-rust first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[4/6] cargo build --release -p chanora_bridge --target $TARGET"
|
||||||
|
export CMAKE_POLICY_VERSION_MINIMUM=3.5
|
||||||
|
export LIBOPUS_STATIC=0
|
||||||
|
cargo build --release --target "$TARGET" -p chanora_bridge
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Bridge .so: $BRIDGE_SO"
|
||||||
|
if [[ ! -f "$BRIDGE_SO" ]]; then
|
||||||
|
echo " ERROR: $BRIDGE_SO not found after build." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 5. flutter build linux --release ----------
|
||||||
|
echo "[5/6] flutter build linux --release"
|
||||||
|
cd "$FLUTTER_APP"
|
||||||
|
flutter pub get
|
||||||
|
flutter build linux --release
|
||||||
|
|
||||||
|
BUNDLE_DIR="$FLUTTER_APP/build/linux/x64/release/bundle"
|
||||||
|
EXE="$BUNDLE_DIR/chanora_flutter"
|
||||||
|
if [[ ! -f "$EXE" ]]; then
|
||||||
|
echo " ERROR: $EXE not found after flutter build." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy the bridge .so into the bundle's lib/ directory so dart:ffi finds it.
|
||||||
|
mkdir -p "$BUNDLE_DIR/lib"
|
||||||
|
cp "$BRIDGE_SO" "$BUNDLE_DIR/lib/"
|
||||||
|
echo " Bundle dir : $BUNDLE_DIR"
|
||||||
|
echo " EXE : $EXE"
|
||||||
|
echo " Bridge .so : $BUNDLE_DIR/lib/libchanora_bridge.so"
|
||||||
|
|
||||||
|
# ---------- 6. Package the bundle ----------
|
||||||
|
echo "[6/6] Package release tarball"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
TAR_NAME="chanora-$VERSION-linux-x64.tar.gz"
|
||||||
|
TAR_PATH="$REPO_ROOT/$TAR_NAME"
|
||||||
|
rm -f "$TAR_PATH"
|
||||||
|
|
||||||
|
# Bundle everything in the bundle directory
|
||||||
|
tar czf "$TAR_PATH" -C "$FLUTTER_APP/build/linux/x64/release/bundle" .
|
||||||
|
echo " Tarball : $TAR_PATH"
|
||||||
|
echo " Size : $(du -h "$TAR_PATH" | cut -f1)"
|
||||||
|
|
||||||
|
bar
|
||||||
|
echo "Linux build complete."
|
||||||
|
bar
|
||||||
|
echo "Artefacts:"
|
||||||
|
echo " Rust lib:"
|
||||||
|
echo " target/$TARGET/release/libchanora_bridge.so"
|
||||||
|
echo " Bundle dir:"
|
||||||
|
echo " $BUNDLE_DIR/"
|
||||||
|
echo " Tarball:"
|
||||||
|
echo " $TAR_PATH"
|
||||||
Reference in New Issue
Block a user