Merge pull request 'An answered ask keeps its question whole, its answer below it (issue 714)' (#299) from fix/an-answered-ask-keeps-its-text into main
This commit was merged in pull request #299.
This commit is contained in:
@@ -49,8 +49,9 @@ channel's client and no channel's secret.
|
|||||||
warrant published to the asker alone. A warrant the bus did not take is published again every minute.
|
warrant published to the asker alone. A warrant the bus did not take is published again every minute.
|
||||||
- **What the operator sees when they answer** (issue 372): first, in reply to the answer where it was given,
|
- **What the operator sees when they answer** (issue 372): first, in reply to the answer where it was given,
|
||||||
that it was recorded and whether its asker was told ("You chose Approve: recorded, and claude-code was
|
that it was recorded and whether its asker was told ("You chose Approve: recorded, and claude-code was
|
||||||
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited to how
|
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited with no
|
||||||
it ended, as its title ("You chose Approve at 13:30 on Telegram · <headline>"), with no answers left on it.
|
answers left on it: its question whole (headline, summary, points, link, Details), and below it how it ended
|
||||||
|
("You chose Approve at 13:30 on Telegram.") and who acts on it (issue 714).
|
||||||
What the asker then did is not said: the asks protocol carries no result back to the router, so the
|
What the asker then did is not said: the asks protocol carries no result back to the router, so the
|
||||||
message says only that the asker was told and acts on it itself.
|
message says only that the asker was told and acts on it itself.
|
||||||
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
|
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
|
||||||
@@ -138,8 +139,9 @@ and said in the log). Everything else is the message's note (`asks.Message.Note`
|
|||||||
smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered;
|
smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered;
|
||||||
and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers
|
and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers
|
||||||
and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to
|
and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to
|
||||||
the secret rule alone, as the whole words are. Answered, the message is one line —
|
the secret rule alone, as the whole words are. Answered, the message keeps its headline, body and Details, so the
|
||||||
"You chose Approve at 16:06 on Telegram · <headline>" — and the router's word on the asker is its note.
|
operator can read back what they answered (issue 714); its note becomes "You chose Approve at 16:06 on Telegram."
|
||||||
|
with the router's word on the asker.
|
||||||
|
|
||||||
**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation
|
**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation
|
||||||
twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they
|
twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they
|
||||||
|
|||||||
@@ -365,10 +365,12 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
|
|||||||
for _, m := range ch.edits {
|
for _, m := range ch.edits {
|
||||||
edited = m
|
edited = m
|
||||||
}
|
}
|
||||||
// The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372).
|
// The question stays whole (issue 714); below it, how it ended and what the router knows of the asker
|
||||||
if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" ||
|
// (issue 372).
|
||||||
edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." {
|
if len(edited.Actions) != 0 || edited.Title != "Delivery of hq held for 36 hours" ||
|
||||||
t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
|
edited.Body != heldAsk(c, "d-1").Explanation ||
|
||||||
|
edited.Note != "You chose Release at 12:00 on Telegram.\nTold to mesh-delivery, which acts on it itself." {
|
||||||
|
t.Errorf("%s still offers %q, or does not keep the question and say how it ended (issue 714): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
|
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
|
||||||
@@ -624,8 +626,8 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
|
|||||||
for _, m := range tg.edits {
|
for _, m := range tg.edits {
|
||||||
edited = m
|
edited = m
|
||||||
}
|
}
|
||||||
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") ||
|
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "Delivery of hq") ||
|
||||||
edited.Body != "" || edited.Note != "The delivery stays held." {
|
edited.Body == "" || edited.Note != "No longer answerable: nobody answered in time.\nThe delivery stays held." {
|
||||||
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
|
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func TestAnApprovalIsConfirmedWhereItWasGivenAndThenTheQuestionSaysHowItEnded(t
|
|||||||
}
|
}
|
||||||
want := []string{
|
want := []string{
|
||||||
"send You chose Release: recorded, and mesh-delivery was told.",
|
"send You chose Release: recorded, and mesh-delivery was told.",
|
||||||
"edit You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours",
|
"edit Delivery of hq held for 36 hours",
|
||||||
}
|
}
|
||||||
if strings.Join(log, "\n") != strings.Join(want, "\n") {
|
if strings.Join(log, "\n") != strings.Join(want, "\n") {
|
||||||
t.Fatalf("the operator saw, in order:\n%s\nwant:\n%s", strings.Join(log, "\n"), strings.Join(want, "\n"))
|
t.Fatalf("the operator saw, in order:\n%s\nwant:\n%s", strings.Join(log, "\n"), strings.Join(want, "\n"))
|
||||||
@@ -122,7 +122,7 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) {
|
|||||||
for _, m := range tg.edits {
|
for _, m := range tg.edits {
|
||||||
edited = m
|
edited = m
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") {
|
if !strings.Contains(edited.Note, "\nTold to mesh-delivery as soon as the bus takes it") {
|
||||||
t.Errorf("the question claims its asker was told: %q", edited.Note)
|
t.Errorf("the question claims its asker was told: %q", edited.Note)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
|
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
|
||||||
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered
|
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note. Once answered
|
||||||
// it is one line with the headline.
|
// it keeps the question whole, so the operator can read back what they answered (issue 714).
|
||||||
|
|
||||||
// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form).
|
// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form).
|
||||||
func decisionAsk(c *clock) asks.Ask {
|
func decisionAsk(c *clock) asks.Ask {
|
||||||
@@ -59,7 +59,8 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
|
|||||||
if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 {
|
if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 {
|
||||||
t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text())
|
t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text())
|
||||||
}
|
}
|
||||||
// Answered, it is one line with the headline; the body is not said again.
|
// Answered, it keeps its headline, summary, points and Details; below them, how it ended and who acts on it;
|
||||||
|
// its buttons are gone (issue 714: the one line issue 639 made left nothing to read back).
|
||||||
if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" {
|
if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" {
|
||||||
t.Fatalf("Approve: %q %v", out, err)
|
t.Fatalf("Approve: %q %v", out, err)
|
||||||
}
|
}
|
||||||
@@ -67,8 +68,9 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
|
|||||||
for _, e := range tg.edits {
|
for _, e := range tg.edits {
|
||||||
edited = e
|
edited = e
|
||||||
}
|
}
|
||||||
if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" ||
|
if edited.Title != a.Headline || edited.Body != a.Explanation+"\n"+want[2]+"\n"+want[3] ||
|
||||||
strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 {
|
edited.Note != "You chose Approve at "+asked+" on Telegram.\nTold to mesh-knowledge, which acts on it itself." ||
|
||||||
|
len(edited.Actions) != 0 {
|
||||||
t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note)
|
t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -856,7 +856,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.Answered != "" {
|
if r.Answered != "" {
|
||||||
m = h.answeredWords(r, m)
|
m = h.answeredWords(r, m, kind)
|
||||||
}
|
}
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
return m
|
return m
|
||||||
@@ -910,18 +910,24 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// answeredWords is a message that says what came of its question. An ask's own message is the question
|
// answeredWords is a message that says what came of its question. An ask's own message keeps the question
|
||||||
// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram ·
|
// whole — its headline as the title, the asker's summary, points and link as the body, its Details after them —
|
||||||
// <headline>" — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body
|
// so the operator can read back what they answered (issue 714, which undid issue 639's one line). How it ended
|
||||||
// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's
|
// ("You chose Approve at 13:30 on Telegram.") and what the router knows of the asker after it are the note,
|
||||||
// message keeps its own title, and says it first.
|
// below the question, and its answers are gone (issue 372). A channel that must shorten it cuts the body from
|
||||||
func (h *Holder) answeredWords(r *Record, m Message) Message {
|
// its end, the Details first; never the headline or the note. A condition's message keeps its own title, and
|
||||||
|
// says how it ended first.
|
||||||
|
func (h *Holder) answeredWords(r *Record, m Message, kind string) Message {
|
||||||
if r.Origin == "ask" {
|
if r.Origin == "ask" {
|
||||||
first, rest, _ := strings.Cut(r.Answered, "\n")
|
|
||||||
headline, _ := wordsOf(r)
|
headline, _ := wordsOf(r)
|
||||||
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest),
|
body := []string{}
|
||||||
|
if b := strings.TrimSpace(m.Body); b != "" {
|
||||||
|
body = append(body, b)
|
||||||
|
}
|
||||||
|
body = append(body, h.answeredDetails(r, kind)...)
|
||||||
|
out := Message{Title: headline, Body: strings.Join(body, "\n"), Note: strings.TrimSpace(r.Answered),
|
||||||
Urgent: m.Urgent, Quiet: m.Quiet}
|
Urgent: m.Urgent, Quiet: m.Quiet}
|
||||||
if _, ok := Check(out.Text()); ok {
|
if _, ok := Check(out.Title + "\n" + out.Note); ok {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
// Its headline refused (it is the asker's): the outcome is still said, before what may leave.
|
// Its headline refused (it is the asker's): the outcome is still said, before what may leave.
|
||||||
@@ -930,6 +936,33 @@ func (h *Holder) answeredWords(r *Record, m Message) Message {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// answeredDetails are an answered ask's Details, line by line, as its open message noted them (issue 714): whole
|
||||||
|
// on a kind that proves who answers, a line that may not leave withheld and said so.
|
||||||
|
func (h *Holder) answeredDetails(r *Record, kind string) []string {
|
||||||
|
if r.Ask == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
e := h.askOf(r.Ask)
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
details, passes := e.rec.Ask.Details, Check
|
||||||
|
if wd := h.wholeDetailsOf(r, kind); wd != "" {
|
||||||
|
details, passes = wd, CheckSecret
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(details), "\n") {
|
||||||
|
if line = strings.TrimSpace(line); line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if refusal, ok := passes(line); !ok {
|
||||||
|
line = withheldLine(line, refusal, passes)
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no
|
// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no
|
||||||
// secret's shape once composed; else false, and the message under the whole content rule stands.
|
// secret's shape once composed; else false, and the message under the whole content rule stands.
|
||||||
func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) {
|
func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func (h *Holder) Preview(key, what string) (map[string]any, error) {
|
|||||||
asking := h.openAsk(&r) != nil // askOf takes the same lock, so this is asked outside it
|
asking := h.openAsk(&r) != nil // askOf takes the same lock, so this is asked outside it
|
||||||
m, line, refusal, ownWords := h.allowedWords(&r, what, now)
|
m, line, refusal, ownWords := h.allowedWords(&r, what, now)
|
||||||
if r.Answered != "" {
|
if r.Answered != "" {
|
||||||
m = h.answeredWords(&r, m)
|
m = h.answeredWords(&r, m, "")
|
||||||
}
|
}
|
||||||
out := map[string]any{
|
out := map[string]any{
|
||||||
"key": key,
|
"key": key,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
|
|||||||
m, edited = r.m, true
|
m, edited = r.m, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
|
if !edited || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") {
|
||||||
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
|
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T)
|
|||||||
if got := openAskRecords(h); len(got) != 0 {
|
if got := openAskRecords(h); len(got) != 0 {
|
||||||
t.Fatalf("the message of an ended ask is still open: %v", got)
|
t.Fatalf("the message of an ended ask is still open: %v", got)
|
||||||
}
|
}
|
||||||
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
|
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") {
|
||||||
t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m))
|
t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes
|
|||||||
if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen {
|
if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen {
|
||||||
t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State)
|
t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State)
|
||||||
}
|
}
|
||||||
// Approved, the message is edited to one line that says so, with the headline; the body is not said again
|
// Approved, the message keeps its headline and words, and says below them how it ended (issue 714).
|
||||||
// (issue 639).
|
|
||||||
if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" {
|
if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" {
|
||||||
t.Fatalf("Release: %q %v", out, err)
|
t.Fatalf("Release: %q %v", out, err)
|
||||||
}
|
}
|
||||||
@@ -70,8 +69,8 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("the phone's message was not edited: %+v", tg.edits)
|
t.Fatalf("the phone's message was not edited: %+v", tg.edits)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") ||
|
if edited.Title != "Set mounts on shanks?" || edited.Body == "" ||
|
||||||
edited.Body != "" || len(edited.Actions) != 0 {
|
!strings.HasPrefix(edited.Note, "You chose Release at ") || len(edited.Actions) != 0 {
|
||||||
t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions)
|
t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ package main
|
|||||||
// formatting", "too much text, no formatting, no summary"). The router's words are shown as given —
|
// formatting", "too much text, no formatting, no summary"). The router's words are shown as given —
|
||||||
// `exact-render` — and formatted, never reworded:
|
// `exact-render` — and formatted, never reworded:
|
||||||
//
|
//
|
||||||
// - the title in bold: the headline, or how an answered ask ended;
|
// - the title in bold: the headline, an answered ask's too (issue 714);
|
||||||
// - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value")
|
// - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value")
|
||||||
// in monospace;
|
// in monospace;
|
||||||
// - the note — who asked, what each answer does, the expiry, the asker's details — apart, after a blank line,
|
// - the note — who asked, what each answer does, the expiry, the asker's details; once answered, how it ended
|
||||||
|
// and who acts on it — apart, after a blank line,
|
||||||
// in a blockquote Telegram shows collapsed to its first lines, expanded on a tap.
|
// in a blockquote Telegram shows collapsed to its first lines, expanded on a tap.
|
||||||
//
|
//
|
||||||
// Every character Telegram's HTML would read as markup is escaped first, so a message carrying '<' neither
|
// Every character Telegram's HTML would read as markup is escaped first, so a message carrying '<' neither
|
||||||
|
|||||||
@@ -84,3 +84,20 @@ func TestTheNoteIsEscaped(t *testing.T) {
|
|||||||
t.Errorf("%q", got)
|
t.Errorf("%q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq issue 714: an answered ask keeps its question whole, with how it ended below it. Over Telegram's bound,
|
||||||
|
// the end of the body (the asker's Details) is cut; the headline and the answer are never.
|
||||||
|
func TestAnAnsweredAskOverTheBoundKeepsItsHeadlineAndItsAnswer(t *testing.T) {
|
||||||
|
answer := "You chose Approve at 20:03 on Telegram.\nTold to mesh-knowledge, which acts on it itself."
|
||||||
|
m := asks.Message{Handle: "h-714", To: "42", Title: "Fixes reach every machine at once, unchecked",
|
||||||
|
Body: "A fix goes to every machine in one step.\n• No machine goes first\n" + strings.Repeat("Details line. ", 400),
|
||||||
|
Note: answer}
|
||||||
|
s := partsOf(m)
|
||||||
|
if n := len([]rune(s.plain())); n > MaxLength {
|
||||||
|
t.Fatalf("the answered ask is %d characters, over %d", n, MaxLength)
|
||||||
|
}
|
||||||
|
if s.title != m.Title || s.note != answer || !strings.HasPrefix(s.body, "A fix goes to every machine in one step.\n• No machine goes first") ||
|
||||||
|
!strings.HasSuffix(s.body, cutNote) {
|
||||||
|
t.Errorf("the headline, the summary or the answer was cut:\n%s", s.plain())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user