fix(pskr): a ten-minute window, both directions, and always an answer
Measured against DXHunter on the same station at the same second: 18 decodes here against 27 there. - The window was five minutes. PSK Reporter's uploaders batch their reports, most of them every five, so a five-minute window catches about one upload cycle per station. Ten, as DXHunter has always had behind a label that says four. - The history query ran once per target and only on the narrow feed. It now runs in both scopes and again every five minutes while a station is watched, which is the cadence the uploaders keep. - It asked only what the target RECEIVED. Both directions now, so "who is hearing him" starts full too. - The suggested call offset looked for a run of empty slots and said nothing when there was none — exactly the case it exists for: a hundred decodes across a 2800 Hz passband leave no gap. Failing a gap it names the quietest slot, ties to the higher offset, never above the ceiling. Chase new: the receiver squares now follow the radius that was asked for (it was one fixed ring whatever the setting said, so raising it bought nothing), and the panel says what the feed is doing rather than leaving an empty list to speak for itself. A change of hunt empties it: its rows are verdicts reached under the old rule and nothing re-judged them. Its own band selection, because what a station CAN work and what is worth watching tonight are different questions.
This commit is contained in:
@@ -319,6 +319,12 @@ type Status struct {
|
|||||||
LastErr string `json:"last_err,omitempty"`
|
LastErr string `json:"last_err,omitempty"`
|
||||||
Broker string `json:"broker"`
|
Broker string `json:"broker"`
|
||||||
Bands []string `json:"bands"`
|
Bands []string `json:"bands"`
|
||||||
|
// What the feed is actually filtered on, so a panel showing nothing can say
|
||||||
|
// WHY instead of leaving the operator to guess: the radius in force, and how
|
||||||
|
// many receiver squares it came to. A radius raised in Preferences that
|
||||||
|
// never reached the subscription is invisible without these two.
|
||||||
|
NearKm int `json:"near_km"`
|
||||||
|
Squares int `json:"squares"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) Status() Status {
|
func (w *Watcher) Status() Status {
|
||||||
@@ -327,6 +333,7 @@ func (w *Watcher) Status() Status {
|
|||||||
st := Status{
|
st := Status{
|
||||||
Running: w.running, Received: w.received, LastAt: w.lastAt,
|
Running: w.running, Received: w.received, LastAt: w.lastAt,
|
||||||
LastErr: w.lastErr, Broker: w.cfg.Broker,
|
LastErr: w.lastErr, Broker: w.cfg.Broker,
|
||||||
|
NearKm: w.cfg.NearKm, Squares: len(w.cfg.RxGrids),
|
||||||
}
|
}
|
||||||
st.Bands = append(st.Bands, w.cfg.Bands...)
|
st.Bands = append(st.Bands, w.cfg.Bands...)
|
||||||
return st
|
return st
|
||||||
|
|||||||
+81
-26
@@ -62,6 +62,12 @@ const (
|
|||||||
// whether it is worth calling at all.
|
// whether it is worth calling at all.
|
||||||
pileupWindow = 2 * time.Minute
|
pileupWindow = 2 * time.Minute
|
||||||
|
|
||||||
|
// backfillEvery is how often the history query is asked again for a target
|
||||||
|
// that is still being watched. Five minutes is PSK Reporter's own courtesy
|
||||||
|
// interval for repeating a query, and it happens to be the period most
|
||||||
|
// uploaders batch on.
|
||||||
|
backfillEvery = 5 * time.Minute
|
||||||
|
|
||||||
// The passband histogram: 60 Hz bins from 200 Hz to 4000 Hz. Above 4 kHz
|
// The passband histogram: 60 Hz bins from 200 Hz to 4000 Hz. Above 4 kHz
|
||||||
// there is essentially no FT8, and drawing the empty space made the strip
|
// there is essentially no FT8, and drawing the empty space made the strip
|
||||||
// look broken rather than empty.
|
// look broken rather than empty.
|
||||||
@@ -202,10 +208,12 @@ type Watcher struct {
|
|||||||
subs []string
|
subs []string
|
||||||
spots []spot
|
spots []spot
|
||||||
|
|
||||||
// backfilled remembers the target the REST history was fetched for, so the
|
// backfilled remembers the target the REST history was fetched for and when,
|
||||||
// panel's polling cannot re-fetch it every second. PSK Reporter's query API
|
// so the panel's polling cannot re-fetch it every second — PSK Reporter's
|
||||||
// answers that with a rate limit, and rightly.
|
// query API answers that with a rate limit, and rightly — while a target
|
||||||
backfilled string
|
// held for a while still gets a fresh look every few minutes.
|
||||||
|
backfilled string
|
||||||
|
backfilledAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) *Watcher {
|
func New(cfg Config) *Watcher {
|
||||||
@@ -282,11 +290,12 @@ func (w *Watcher) Watch(target, mode string, dialHz int64) error {
|
|||||||
if err := w.resubscribe(client); err != nil {
|
if err := w.resubscribe(client); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if changed && w.cfg.Scope == ScopeTarget {
|
if changed {
|
||||||
// Under the band-wide subscription the window is already full of the new
|
// In BOTH scopes. The band-wide subscription was assumed to arrive with
|
||||||
// target's reports; under the narrow one it is empty, and the REST query
|
// the target's reports already in the window — true only once it has been
|
||||||
// is what makes the panel useful in the first fifteen seconds instead of
|
// running a while, and false in the case that matters: the operator picks
|
||||||
// after five minutes.
|
// a station a minute after opening the panel and sees one decode where
|
||||||
|
// another program, running for an hour, shows four.
|
||||||
go w.backfill(target, mode)
|
go w.backfill(target, mode)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -377,7 +386,7 @@ func (w *Watcher) connect() (mqtt.Client, error) {
|
|||||||
if err := w.resubscribe(c); err != nil {
|
if err := w.resubscribe(c); err != nil {
|
||||||
w.cfg.Logf("pskr target: %v", err)
|
w.cfg.Logf("pskr target: %v", err)
|
||||||
}
|
}
|
||||||
if target != "" && w.cfg.Scope == ScopeTarget {
|
if target != "" {
|
||||||
go w.backfill(target, mode)
|
go w.backfill(target, mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,7 +458,15 @@ func (w *Watcher) SetOperator(call, grid string) {
|
|||||||
|
|
||||||
// Snapshot recomputes the analysis from the window.
|
// Snapshot recomputes the analysis from the window.
|
||||||
func (w *Watcher) Snapshot() Analysis {
|
func (w *Watcher) Snapshot() Analysis {
|
||||||
|
// Due another look at the history? Checked here because this is what the
|
||||||
|
// panel calls every second; the query itself is rate-limited inside
|
||||||
|
// backfill, so this cannot turn into a request per poll.
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
|
if t, m := w.target, w.mode; t != "" && time.Since(w.backfilledAt) >= backfillEvery {
|
||||||
|
w.mu.Unlock()
|
||||||
|
go w.backfill(t, m)
|
||||||
|
w.mu.Lock()
|
||||||
|
}
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -633,34 +650,42 @@ func top(m map[string]Entry, limit int) []Entry {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// suggestOffset picks an audio slot to call on: the middle of the widest run of
|
// suggestOffset picks an audio slot to call on.
|
||||||
// empty bins below the ceiling.
|
|
||||||
//
|
//
|
||||||
// Below the CEILING, not below 4000 Hz. The ceiling is the highest offset he
|
// Below the CEILING, not below 4000 Hz. The ceiling is the highest offset he
|
||||||
// has actually decoded, and it is the only evidence available about how wide
|
// has actually decoded, and it is the only evidence available about how wide
|
||||||
// his receiver is set — plenty of stations run 2500 Hz. Suggesting 3400 Hz to
|
// his receiver is set — plenty of stations run 2500 Hz. Suggesting 3400 Hz to
|
||||||
// somebody whose passband stops at 2700 is advice to transmit into a filter.
|
// somebody whose passband stops at 2700 is advice to transmit into a filter.
|
||||||
|
//
|
||||||
|
// Two passes, and the second is the one that matters on a busy DX. Looking for
|
||||||
|
// an empty run alone answered "nowhere" exactly when the answer was most
|
||||||
|
// wanted: a hundred decodes across a 2800 Hz passband leave no run of clear
|
||||||
|
// bins at all, and the panel drew a full histogram with no advice under it.
|
||||||
|
// Failing a real gap, the quietest slot is still better than the one the
|
||||||
|
// operator would have picked by eye.
|
||||||
func suggestOffset(bins []Bin, ceiling int) int {
|
func suggestOffset(bins []Bin, ceiling int) int {
|
||||||
if ceiling < 1000 {
|
if ceiling < 1000 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
used := map[int]bool{}
|
count := make(map[int]int, len(bins))
|
||||||
|
busy := make(map[int]bool, len(bins)*3)
|
||||||
for _, b := range bins {
|
for _, b := range bins {
|
||||||
|
count[b.OffsetHz] = b.Count
|
||||||
if b.Count > 0 {
|
if b.Count > 0 {
|
||||||
used[b.OffsetHz] = true
|
|
||||||
// The neighbours too: FT8 is 50 Hz wide and the bins are 60, so a
|
// The neighbours too: FT8 is 50 Hz wide and the bins are 60, so a
|
||||||
// signal on a bin edge covers the next one as surely as its own.
|
// signal on a bin edge covers the next one as surely as its own.
|
||||||
used[b.OffsetHz-binHz] = true
|
busy[b.OffsetHz], busy[b.OffsetHz-binHz], busy[b.OffsetHz+binHz] = true, true, true
|
||||||
used[b.OffsetHz+binHz] = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bestStart, bestLen := -1, 0
|
|
||||||
start, run := -1, 0
|
|
||||||
// From 1000 Hz up: below that is where every default transmit offset sits,
|
// From 1000 Hz up: below that is where every default transmit offset sits,
|
||||||
// so it is the most crowded part of the passband and the least useful
|
// so it is the most crowded part of the passband and the least useful advice.
|
||||||
// advice.
|
const low = 1020
|
||||||
for edge := 1020; edge+binHz <= ceiling; edge += binHz {
|
high := (ceiling / binHz) * binHz
|
||||||
if used[edge] {
|
|
||||||
|
// Pass 1 — the widest clear run, and call from its middle.
|
||||||
|
bestStart, bestLen, start, run := -1, 0, -1, 0
|
||||||
|
for edge := low; edge <= high; edge += binHz {
|
||||||
|
if busy[edge] {
|
||||||
start, run = -1, 0
|
start, run = -1, 0
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -672,10 +697,32 @@ func suggestOffset(bins []Bin, ceiling int) int {
|
|||||||
bestStart, bestLen = start, run
|
bestStart, bestLen = start, run
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bestStart < 0 || bestLen < 2 {
|
if bestStart >= 0 && bestLen >= 2 {
|
||||||
|
return bestStart + bestLen*binHz/2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2 — no clear run: the least busy slot. Walked from the top down, so
|
||||||
|
// a tie goes to the higher offset, which is the less crowded half of any
|
||||||
|
// passband and the half a pile-up leaves alone.
|
||||||
|
quietest, fewest := -1, 1<<30
|
||||||
|
for edge := high; edge >= low; edge -= binHz {
|
||||||
|
if c := count[edge]; c < fewest {
|
||||||
|
quietest, fewest = edge, c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quietest < 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return bestStart + bestLen*binHz/2
|
// Never past the ceiling: the top bin CONTAINS it, so its middle can sit
|
||||||
|
// beyond the highest offset he has been shown to decode — which is the one
|
||||||
|
// thing this function exists to avoid.
|
||||||
|
if quietest+binHz/2 > ceiling {
|
||||||
|
quietest -= binHz
|
||||||
|
}
|
||||||
|
if quietest < low {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return quietest + binHz/2
|
||||||
}
|
}
|
||||||
|
|
||||||
// bandTag names the band a dial frequency is on, in PSK Reporter's own
|
// bandTag names the band a dial frequency is on, in PSK Reporter's own
|
||||||
@@ -755,11 +802,19 @@ type pskrReports struct {
|
|||||||
// is what gets an application rate-limited off the service for everyone.
|
// is what gets an application rate-limited off the service for everyone.
|
||||||
func (w *Watcher) backfill(target, mode string) {
|
func (w *Watcher) backfill(target, mode string) {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
if w.backfilled == target {
|
// Once per target, then no more often than the refresh interval.
|
||||||
|
//
|
||||||
|
// The live feed alone lags by design: PSK Reporter's uploaders batch their
|
||||||
|
// reports, most of them every five minutes, so between two batches the
|
||||||
|
// window only holds what happened to have been sent. Asking the history
|
||||||
|
// again at that same cadence keeps it as full as a program that has been
|
||||||
|
// subscribed for an hour — which is the whole of the difference an operator
|
||||||
|
// sees when comparing the two side by side.
|
||||||
|
if w.backfilled == target && time.Since(w.backfilledAt) < backfillEvery {
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.backfilled = target
|
w.backfilled, w.backfilledAt = target, time.Now()
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
|
|
||||||
got := 0
|
got := 0
|
||||||
|
|||||||
@@ -99,10 +99,12 @@ func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) {
|
|||||||
if got < 1620 || got > 2340 {
|
if got < 1620 || got > 2340 {
|
||||||
t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got)
|
t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got)
|
||||||
}
|
}
|
||||||
// A passband that stops low must not produce advice above it: transmitting
|
// A passband with no gap at all still gets an answer — the quietest slot,
|
||||||
// past the DX's filter is the one outcome worse than picking a busy slot.
|
// which is what an operator would look for by eye. What it must never do is
|
||||||
if got := suggestOffset(bins, 1500); got != 0 {
|
// advise ABOVE the ceiling: transmitting past the DX's filter is the one
|
||||||
t.Errorf("suggested %d Hz with a 1500 Hz ceiling and no room, want none", got)
|
// outcome worse than picking a busy slot.
|
||||||
|
if got := suggestOffset(bins, 1500); got <= 1000 || got > 1500 {
|
||||||
|
t.Errorf("suggested %d Hz with a full 1500 Hz passband, want a slot inside it", got)
|
||||||
}
|
}
|
||||||
if got := suggestOffset(nil, 0); got != 0 {
|
if got := suggestOffset(nil, 0); got != 0 {
|
||||||
t.Errorf("suggested %d Hz with no data at all, want none", got)
|
t.Errorf("suggested %d Hz with no data at all, want none", got)
|
||||||
@@ -132,3 +134,25 @@ func TestTopicsFollowTheScope(t *testing.T) {
|
|||||||
t.Errorf("band scope = %v, want the one band-wide filter", got)
|
t.Errorf("band scope = %v, want the one band-wide filter", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The case reported from the air: a busy DX, 103 decodes across a 2805 Hz
|
||||||
|
// passband, and the panel drew the whole histogram with no advice under it.
|
||||||
|
func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) {
|
||||||
|
// Every bin from 1020 to 2800 occupied — no clear run anywhere, and the
|
||||||
|
// quietest slot is the answer.
|
||||||
|
bins := []Bin{}
|
||||||
|
for hz := 1020; hz <= 2760; hz += binHz {
|
||||||
|
n := 5
|
||||||
|
if hz == 2400 { // one slot noticeably quieter than the rest
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
bins = append(bins, Bin{OffsetHz: hz, Count: n})
|
||||||
|
}
|
||||||
|
got := suggestOffset(bins, 2805)
|
||||||
|
if got == 0 {
|
||||||
|
t.Fatal("no advice on a full passband — this is exactly when it is wanted")
|
||||||
|
}
|
||||||
|
if got < 2400 || got > 2460 {
|
||||||
|
t.Errorf("suggested %d Hz, want the quietest slot around 2400", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+90
-1
@@ -36,6 +36,28 @@ import (
|
|||||||
// have one without the other. They only share the feed, which either one starts.
|
// have one without the other. They only share the feed, which either one starts.
|
||||||
const keyChaseNew = "cluster.chase_new"
|
const keyChaseNew = "cluster.chase_new"
|
||||||
|
|
||||||
|
// keyChaseNewBandsOff lists the bands the panel is NOT to show, comma
|
||||||
|
// separated.
|
||||||
|
//
|
||||||
|
// Stored as the EXCLUSIONS rather than the selection, so that an empty setting
|
||||||
|
// — every operator who has never opened this — means "show them all", and so
|
||||||
|
// that a band added to the station's list later appears here on its own. Stored
|
||||||
|
// the other way round, a selection saved today would silently hide 4 m the day
|
||||||
|
// a transverter arrives.
|
||||||
|
const keyChaseNewBandsOff = "cluster.chase_new_bands_off"
|
||||||
|
|
||||||
|
// ChaseNewBands is the band vocabulary of the panel's own filter: HF through
|
||||||
|
// 70 cm, in the order an operator reads a band plan.
|
||||||
|
//
|
||||||
|
// A fixed list, not the station's: the two answer different questions. The
|
||||||
|
// station list says what this station can work at all, and still applies —
|
||||||
|
// this one says what is worth WATCHING right now, which changes with the hour
|
||||||
|
// and the season, not with the shack.
|
||||||
|
var ChaseNewBands = []string{
|
||||||
|
"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
|
||||||
|
"6m", "4m", "2m", "70cm",
|
||||||
|
}
|
||||||
|
|
||||||
// chaseNewMax bounds the panel. A list nobody can read to the bottom is not more
|
// chaseNewMax bounds the panel. A list nobody can read to the bottom is not more
|
||||||
// information, and the oldest rows are the least likely to still be on the air.
|
// information, and the oldest rows are the least likely to still be on the air.
|
||||||
const chaseNewMax = 200
|
const chaseNewMax = 200
|
||||||
@@ -149,6 +171,69 @@ var chaseModes = map[string]bool{
|
|||||||
// atomic rather than the settings store.
|
// atomic rather than the settings store.
|
||||||
func (a *App) chaseNewEnabled() bool { return a.chaseNewOn.Load() }
|
func (a *App) chaseNewEnabled() bool { return a.chaseNewOn.Load() }
|
||||||
|
|
||||||
|
// GetChaseNewBands returns the bands the panel is set to SHOW — every band in
|
||||||
|
// the vocabulary that has not been switched off.
|
||||||
|
func (a *App) GetChaseNewBands() []string {
|
||||||
|
off := map[string]bool{}
|
||||||
|
for _, b := range splitCSV(a.settingOr(keyChaseNewBandsOff, "")) {
|
||||||
|
off[strings.ToLower(strings.TrimSpace(b))] = true
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(ChaseNewBands))
|
||||||
|
for _, b := range ChaseNewBands {
|
||||||
|
if !off[b] {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChaseNewBands takes the bands to SHOW and stores the complement.
|
||||||
|
//
|
||||||
|
// Nothing is filtered out of the store retroactively: the rows already
|
||||||
|
// collected stay until they age out, and the next message is the one that
|
||||||
|
// obeys. A panel that emptied itself on a settings click would look like it had
|
||||||
|
// lost the feed.
|
||||||
|
func (a *App) SetChaseNewBands(show []string) {
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, b := range show {
|
||||||
|
want[strings.ToLower(strings.TrimSpace(b))] = true
|
||||||
|
}
|
||||||
|
var off []string
|
||||||
|
for _, b := range ChaseNewBands {
|
||||||
|
if !want[b] {
|
||||||
|
off = append(off, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.setSetting(keyChaseNewBandsOff, strings.Join(off, ","))
|
||||||
|
a.refreshChaseNewBands()
|
||||||
|
applog.Printf("chase new: showing %d of %d bands", len(ChaseNewBands)-len(off), len(ChaseNewBands))
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshChaseNewBands re-reads the panel's own band filter into the cache the
|
||||||
|
// feed consults. Called at startup, and whenever the selection is saved.
|
||||||
|
func (a *App) refreshChaseNewBands() {
|
||||||
|
off := map[string]bool{}
|
||||||
|
for _, b := range splitCSV(a.settingOr(keyChaseNewBandsOff, "")) {
|
||||||
|
if b = strings.ToLower(strings.TrimSpace(b)); b != "" {
|
||||||
|
off[b] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.chaseNewBandsOff.Store(off)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chaseNewBandShown is the per-message half: a map read on the MQTT goroutine.
|
||||||
|
func (a *App) chaseNewBandShown(band string) bool {
|
||||||
|
v := a.chaseNewBandsOff.Load()
|
||||||
|
if v == nil {
|
||||||
|
return true // nothing loaded yet — better to show than to swallow
|
||||||
|
}
|
||||||
|
off, ok := v.(map[string]bool)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !off[strings.ToLower(strings.TrimSpace(band))]
|
||||||
|
}
|
||||||
|
|
||||||
// chaseBandAllowed reports whether a band is one the operator uses.
|
// chaseBandAllowed reports whether a band is one the operator uses.
|
||||||
//
|
//
|
||||||
// PSK Reporter carries every band its receivers listen on, including microwave
|
// PSK Reporter carries every band its receivers listen on, including microwave
|
||||||
@@ -217,7 +302,11 @@ func (a *App) feedChaseNew(sp pskr.Spot) {
|
|||||||
// Both tests before the status lookup: they are map reads on short keys and
|
// Both tests before the status lookup: they are map reads on short keys and
|
||||||
// they throw away most of the feed, so nothing further down pays for a band
|
// they throw away most of the feed, so nothing further down pays for a band
|
||||||
// this station cannot work or a mode nobody answers.
|
// this station cannot work or a mode nobody answers.
|
||||||
if !chaseModes[mode] || !a.chaseBandAllowed(band) {
|
// Two band tests, and they answer two questions: can this station work the
|
||||||
|
// band at all (its own list), and does the operator want to WATCH it right
|
||||||
|
// now (this panel's own filter). A station with 2 m equipment that is
|
||||||
|
// chasing HF tonight needs the second one.
|
||||||
|
if !chaseModes[mode] || !a.chaseBandAllowed(band) || !a.chaseNewBandShown(band) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user