feat: added live status for TM74TFR
This commit is contained in:
@@ -769,6 +769,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
|
// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
|
||||||
// when disabled in Preferences or until the PostHog key is configured.
|
// when disabled in Preferences or until the PostHog key is configured.
|
||||||
go a.sendTelemetryHeartbeat()
|
go a.sendTelemetryHeartbeat()
|
||||||
|
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
|
||||||
|
|
||||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* OpsLog multi-operator LIVE STATUS renderer.
|
||||||
|
*
|
||||||
|
* Reads the shared `live_status` table that every OpsLog instance heartbeats
|
||||||
|
* (operator call + station call + freq/band/mode, refreshed every ~15s) and
|
||||||
|
* shows the operators active in the last 2 minutes.
|
||||||
|
*
|
||||||
|
* Put this file on YOUR web server (the one reachable from the internet), point
|
||||||
|
* it at the SAME MySQL database OpsLog uses for the shared logbook, and embed it
|
||||||
|
* on the QRZ.com bio of the station call:
|
||||||
|
*
|
||||||
|
* <img src="https://your-server/tm74-status.php?img=1"> (image, cached ~min by QRZ)
|
||||||
|
* or <a href="https://your-server/tm74-status.php">Live operators</a> (real-time page)
|
||||||
|
*
|
||||||
|
* QRZ strips <script>/<iframe>, so only an <img> auto-updates the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||||
|
$DB_NAME = 'opslog'; // database name
|
||||||
|
$DB_USER = 'opslog';
|
||||||
|
$DB_PASS = 'CHANGE_ME';
|
||||||
|
$STALE_SECONDS = 120; // an operator is "active" if seen within this window
|
||||||
|
|
||||||
|
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||||
|
if ($mysqli->connect_errno) {
|
||||||
|
http_response_code(500);
|
||||||
|
exit('DB error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$sql = "SELECT operator, station, freq_hz, band, mode, updated_at
|
||||||
|
FROM live_status
|
||||||
|
WHERE updated_at >= UTC_TIMESTAMP() - INTERVAL ? SECOND
|
||||||
|
ORDER BY band, freq_hz";
|
||||||
|
if ($stmt = $mysqli->prepare($sql)) {
|
||||||
|
$stmt->bind_param('i', $STALE_SECONDS);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
$station = $rows ? $rows[0]['station'] : 'OpsLog';
|
||||||
|
$fmtFreq = function ($hz) { return $hz > 0 ? number_format($hz / 1e6, 3) . ' MHz' : '—'; };
|
||||||
|
|
||||||
|
// ── Image output (SVG) for the QRZ <img> embed: ?img=1 ──────────────────────
|
||||||
|
if (isset($_GET['img'])) {
|
||||||
|
header('Content-Type: image/svg+xml');
|
||||||
|
header('Cache-Control: no-cache, max-age=30');
|
||||||
|
$rowH = 26; $h = 44 + max(1, count($rows)) * $rowH; $w = 440;
|
||||||
|
echo "<svg xmlns='http://www.w3.org/2000/svg' width='$w' height='$h' font-family='Segoe UI,Arial'>";
|
||||||
|
echo "<rect width='$w' height='$h' rx='8' fill='#0f172a'/>";
|
||||||
|
echo "<text x='14' y='26' fill='#38bdf8' font-size='15' font-weight='bold'>" . htmlspecialchars($station) . " — live operators</text>";
|
||||||
|
$y = 44 + 18;
|
||||||
|
if (!$rows) {
|
||||||
|
echo "<text x='14' y='$y' fill='#94a3b8' font-size='13'>No operator active right now.</text>";
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$line = sprintf('%-10s %-4s %-9s %s', $r['operator'], $r['band'], $r['mode'], $fmtFreq($r['freq_hz']));
|
||||||
|
echo "<text x='14' y='$y' fill='#e2e8f0' font-size='13' font-family='monospace'>" . htmlspecialchars($line) . "</text>";
|
||||||
|
$y += $rowH;
|
||||||
|
}
|
||||||
|
echo "</svg>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML page (real-time when opened directly; auto-refreshes every 20s) ─────
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="20">
|
||||||
|
<title><?= htmlspecialchars($station) ?> — live operators</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Segoe UI, Arial, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:16px; }
|
||||||
|
h1 { color:#38bdf8; font-size:18px; margin:0 0 12px; }
|
||||||
|
table { border-collapse:collapse; width:100%; max-width:560px; }
|
||||||
|
th,td { text-align:left; padding:6px 12px; border-bottom:1px solid #1e293b; font-size:14px; }
|
||||||
|
th { color:#94a3b8; text-transform:uppercase; font-size:11px; letter-spacing:.05em; }
|
||||||
|
td.mono { font-family:monospace; }
|
||||||
|
.none { color:#94a3b8; }
|
||||||
|
</style></head><body>
|
||||||
|
<h1><?= htmlspecialchars($station) ?> — operators active now</h1>
|
||||||
|
<?php if (!$rows): ?>
|
||||||
|
<p class="none">No operator active right now.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table>
|
||||||
|
<tr><th>Operator</th><th>Band</th><th>Mode</th><th>Frequency</th></tr>
|
||||||
|
<?php foreach ($rows as $r): ?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?= htmlspecialchars($r['operator']) ?></strong></td>
|
||||||
|
<td><?= htmlspecialchars($r['band']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($r['mode']) ?></td>
|
||||||
|
<td class="mono"><?= $fmtFreq($r['freq_hz']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</body></html>
|
||||||
+10
-7
@@ -2063,6 +2063,14 @@ export default function App() {
|
|||||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
// DX country flag, shown large next to RST (moved here from the Country field).
|
||||||
|
const flagBlock = flagURL(details.dxcc) ? (
|
||||||
|
<div className="flex flex-col justify-end shrink-0">
|
||||||
|
<img src={flagURL(details.dxcc)} alt={country} title={country}
|
||||||
|
className="h-9 rounded-[3px] border border-border/60 shadow-sm"
|
||||||
|
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
// Deferred-entry date: only shown when the start time is locked (back-entering
|
// Deferred-entry date: only shown when the start time is locked (back-entering
|
||||||
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
|
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
|
||||||
const dateBlock = locks.start ? (
|
const dateBlock = locks.start ? (
|
||||||
@@ -2185,13 +2193,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const countryRow = (
|
const countryRow = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="w-20 shrink-0 flex items-center gap-1.5">
|
<Label className="w-20 shrink-0">Country</Label>
|
||||||
Country
|
|
||||||
{flagURL(details.dxcc) && (
|
|
||||||
<img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm mr-0.5"
|
|
||||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
|
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2927,6 +2929,7 @@ export default function App() {
|
|||||||
{callsignBlock}
|
{callsignBlock}
|
||||||
{rstTxBlock}
|
{rstTxBlock}
|
||||||
{rstRxBlock}
|
{rstRxBlock}
|
||||||
|
{flagBlock}
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
{dateBlock}
|
{dateBlock}
|
||||||
{startBlock}
|
{startBlock}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
GetDataDir,
|
GetDataDir,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
|
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
@@ -448,6 +449,25 @@ function TelemetryToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LiveStatusToggle publishes this operator's current activity (call + band +
|
||||||
|
// freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op
|
||||||
|
// events — a small web script on your server renders it for the QRZ page. Only
|
||||||
|
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||||
|
function LiveStatusToggle() {
|
||||||
|
const [on, setOn] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetLiveStatusEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||||
|
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// MainViewPanes lets the operator choose what the Main tab's left and right
|
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||||
// panes show, independently: the great-circle map, the locator street map, the
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||||
@@ -3351,6 +3371,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
||||||
</label>
|
</label>
|
||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
|
<LiveStatusToggle />
|
||||||
|
|
||||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
|
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
|
||||||
|
|
||||||
|
|||||||
Vendored
+4
@@ -217,6 +217,8 @@ export function GetFlexState():Promise<cat.FlexTXState>;
|
|||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
export function GetLogbookRevision():Promise<string>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
@@ -449,6 +451,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -406,6 +406,10 @@ export function GetListsSettings() {
|
|||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLiveStatusEnabled() {
|
||||||
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogFilePath() {
|
export function GetLogFilePath() {
|
||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
@@ -870,6 +874,10 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetLiveStatusEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Live operator status — for multi-operator events on a SHARED MySQL logbook
|
||||||
|
// (e.g. a special-event call like TM74FR with several ops on different bands).
|
||||||
|
// Each OpsLog instance heartbeats its current activity (operator call + station
|
||||||
|
// call + freq/band/mode from CAT) into a `live_status` table every ~15s. A tiny
|
||||||
|
// web script on the operator's own server reads that table and renders a live
|
||||||
|
// page/image that the QRZ.com bio can embed (`<img src=…>`). OpsLog only WRITES
|
||||||
|
// to the DB — it is not a web server. Rows older than a couple of minutes are
|
||||||
|
// "stale" (operator went offline); the web side ignores them.
|
||||||
|
|
||||||
|
const keyLiveStatusEnabled = "livestatus.enabled"
|
||||||
|
|
||||||
|
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||||
|
func (a *App) GetLiveStatusEnabled() bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled)
|
||||||
|
return strings.TrimSpace(v) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLiveStatusEnabled turns live-status publishing on or off (off also removes
|
||||||
|
// this operator's row immediately).
|
||||||
|
func (a *App) SetLiveStatusEnabled(on bool) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
val := "0"
|
||||||
|
if on {
|
||||||
|
val = "1"
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
go a.publishLiveStatus() // show up right away
|
||||||
|
} else {
|
||||||
|
a.clearLiveStatus()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||||
|
// startup; cheap no-op when disabled or not on MySQL.
|
||||||
|
func (a *App) liveStatusLoop() {
|
||||||
|
t := time.NewTicker(15 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
a.clearLiveStatus()
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
a.publishLiveStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusActive reports whether publishing should run (MySQL logbook + on).
|
||||||
|
func (a *App) liveStatusActive() bool {
|
||||||
|
return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusOperator returns this instance's operator id (the operator callsign,
|
||||||
|
// falling back to the station callsign for a single-op setup).
|
||||||
|
func (a *App) liveStatusOperator() (op, station string) {
|
||||||
|
if a.settings == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
o, _ := a.settings.Get(a.ctx, keyStationOperator)
|
||||||
|
s, _ := a.settings.Get(a.ctx, keyStationCallsign)
|
||||||
|
op = strings.ToUpper(strings.TrimSpace(o))
|
||||||
|
station = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if op == "" {
|
||||||
|
op = station
|
||||||
|
}
|
||||||
|
return op, station
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishLiveStatus upserts this operator's current activity. Best effort.
|
||||||
|
func (a *App) publishLiveStatus() {
|
||||||
|
if !a.liveStatusActive() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op, station := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var freqHz int64
|
||||||
|
var band, mode string
|
||||||
|
if a.cat != nil {
|
||||||
|
st := a.cat.State()
|
||||||
|
if st.Connected {
|
||||||
|
freqHz, band, mode = st.FreqHz, st.Band, st.Mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
|
applog.Printf("livestatus: ensure table: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
||||||
|
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||||
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||||
|
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station, freqHz, band, mode)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("livestatus: publish: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureLiveStatusTable() error {
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
|
"station VARCHAR(32), "+
|
||||||
|
"freq_hz BIGINT, "+
|
||||||
|
"band VARCHAR(16), "+
|
||||||
|
"mode VARCHAR(16), "+
|
||||||
|
"updated_at DATETIME)")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||||
|
func (a *App) clearLiveStatus() {
|
||||||
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op, _ := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user