@blogmcp/community 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-DzjwnHyx.js +4597 -0
- package/dist/core/attrs.d.ts +73 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/discussion.d.ts +144 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +17 -0
- package/dist/core/i18n.d.ts +28 -0
- package/dist/core/names.d.ts +15 -0
- package/dist/core/notice.d.ts +50 -0
- package/dist/core/onboarding.d.ts +52 -0
- package/dist/core/outcomes.d.ts +28 -0
- package/dist/core/panel.d.ts +80 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/text.d.ts +22 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +26 -0
- package/dist/global.d.ts +8 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +22 -0
- package/dist/mount.d.ts +65 -0
- package/dist/react.d.ts +17 -0
- package/dist/react.js +35 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +33 -0
- package/dist/ui/LinkedText.vue.d.ts +5 -0
- package/dist/ui/MessageComposer.vue.d.ts +24 -0
- package/dist/ui/MessageItem.vue.d.ts +6 -0
- package/dist/ui/MessageMenu.vue.d.ts +10 -0
- package/dist/ui/MyCommunityView.vue.d.ts +16 -0
- package/dist/ui/NoticeFormView.vue.d.ts +26 -0
- package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ReactionBar.vue.d.ts +13 -0
- package/dist/ui/ReportDialog.vue.d.ts +15 -0
- package/dist/ui/StatementView.vue.d.ts +15 -0
- package/dist/ui/context.d.ts +21 -0
- package/dist/ui/discussion-context.d.ts +19 -0
- package/dist/ui/types.d.ts +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/vue.d.ts +89 -0
- package/dist/vue.js +97 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +147 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/client.ts +54 -0
- package/src/core/discussion.ts +691 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +90 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/names.ts +51 -0
- package/src/core/notice.ts +178 -0
- package/src/core/onboarding.ts +345 -0
- package/src/core/outcomes.ts +74 -0
- package/src/core/panel.ts +411 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/text.ts +142 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +109 -0
- package/src/global.ts +12 -0
- package/src/index.ts +35 -0
- package/src/mount.ts +127 -0
- package/src/react.ts +53 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +294 -0
- package/src/ui/LinkedText.vue +18 -0
- package/src/ui/MessageComposer.vue +130 -0
- package/src/ui/MessageItem.vue +205 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +369 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/OnboardingSteps.vue +163 -0
- package/src/ui/ReactionBar.vue +41 -0
- package/src/ui/ReportDialog.vue +88 -0
- package/src/ui/StatementView.vue +141 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +243 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +116 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The member's export as a file the browser saves (§8.3: the download is the SDK's). Nothing leaves the page. */
|
|
2
|
+
export function saveJson(data: unknown, fileName: string): void {
|
|
3
|
+
if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return
|
|
4
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
|
5
|
+
const url = URL.createObjectURL(blob)
|
|
6
|
+
const a = document.createElement('a')
|
|
7
|
+
a.href = url
|
|
8
|
+
a.download = fileName
|
|
9
|
+
a.style.display = 'none'
|
|
10
|
+
document.body.appendChild(a)
|
|
11
|
+
a.click()
|
|
12
|
+
a.remove()
|
|
13
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `<name>-YYYY-MM-DD.json`, the name being the catalogue's (`panel.exportFileName`). */
|
|
17
|
+
export function exportFileName(name: string, now: Date = new Date()): string {
|
|
18
|
+
const day = now.toISOString().slice(0, 10)
|
|
19
|
+
const safe = name.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '') || 'export'
|
|
20
|
+
return `${safe}-${day}.json`
|
|
21
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates and a statement's territorial scope as the reader's language writes them (Intl does the words; the
|
|
3
|
+
* catalogue holds the sentence around them).
|
|
4
|
+
*/
|
|
5
|
+
import { translator, type Lang } from './i18n'
|
|
6
|
+
|
|
7
|
+
/** The Intl locale a language reads. */
|
|
8
|
+
const localeOf = (lang: Lang): string => (lang === 'cs' ? 'cs-CZ' : 'en-GB')
|
|
9
|
+
|
|
10
|
+
const formats = new Map<string, Intl.DateTimeFormat | null>()
|
|
11
|
+
|
|
12
|
+
function format(lang: Lang, withTime: boolean): Intl.DateTimeFormat | null {
|
|
13
|
+
const key = `${lang}:${withTime ? 't' : 'd'}`
|
|
14
|
+
if (!formats.has(key)) {
|
|
15
|
+
let f: Intl.DateTimeFormat | null = null
|
|
16
|
+
try {
|
|
17
|
+
f = new Intl.DateTimeFormat(localeOf(lang), withTime ? { dateStyle: 'medium', timeStyle: 'short' } : { dateStyle: 'long' })
|
|
18
|
+
} catch {
|
|
19
|
+
// A runtime without Intl: the ISO text stays.
|
|
20
|
+
}
|
|
21
|
+
formats.set(key, f)
|
|
22
|
+
}
|
|
23
|
+
return formats.get(key) ?? null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An ISO timestamp as a date (and time); '' for anything that is no date. */
|
|
27
|
+
export function formatDate(lang: Lang, iso: string | null | undefined, withTime = true): string {
|
|
28
|
+
if (typeof iso !== 'string' || iso === '') return ''
|
|
29
|
+
const at = Date.parse(iso)
|
|
30
|
+
if (!Number.isFinite(at)) return ''
|
|
31
|
+
return format(lang, withTime)?.format(new Date(at)) ?? iso
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether an ISO timestamp lies in the past (an appeal window, a sanction's end). */
|
|
35
|
+
export function isPast(iso: string | null | undefined, now: number = Date.now()): boolean {
|
|
36
|
+
if (typeof iso !== 'string') return false
|
|
37
|
+
const at = Date.parse(iso)
|
|
38
|
+
return Number.isFinite(at) && at <= now
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `trust.decisions.territorial_scope`'s other shape: ISO 3166-1 alpha-2 codes, comma-separated (its CHECK). */
|
|
42
|
+
const REGION_LIST = /^[A-Z]{2}(?:,[A-Z]{2})*$/
|
|
43
|
+
|
|
44
|
+
const regionNames = new Map<Lang, Intl.DisplayNames | null>()
|
|
45
|
+
const regionLists = new Map<Lang, Intl.ListFormat | null>()
|
|
46
|
+
|
|
47
|
+
function cached<T>(cache: Map<Lang, T | null>, lang: Lang, make: (locale: string) => T): T | null {
|
|
48
|
+
if (!cache.has(lang)) {
|
|
49
|
+
let value: T | null = null
|
|
50
|
+
try {
|
|
51
|
+
value = make(localeOf(lang))
|
|
52
|
+
} catch {
|
|
53
|
+
// A runtime without this part of Intl: the codes stay.
|
|
54
|
+
}
|
|
55
|
+
cache.set(lang, value)
|
|
56
|
+
}
|
|
57
|
+
return cache.get(lang) ?? null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A region's name in the language (`CZ` → „Česko"), or the code when Intl has none. */
|
|
61
|
+
function regionName(lang: Lang, code: string): string {
|
|
62
|
+
try {
|
|
63
|
+
return cached(regionNames, lang, (locale) => new Intl.DisplayNames(locale, { type: 'region' }))?.of(code) || code
|
|
64
|
+
} catch {
|
|
65
|
+
return code
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A statement's territorial scope (DSA art. 17(3)(a)) as the reader reads it. kb-api stores `all` — the measure
|
|
71
|
+
* applies without a territorial limit, the catalogue's `statement.scopeAll` — or the regions it is limited to as
|
|
72
|
+
* ISO 3166-1 alpha-2 codes (`CZ,SK`), which Intl names and joins („Česko a Slovensko"; a runtime without Intl reads
|
|
73
|
+
* `CZ, SK`). Any other value — one a newer kb-api sends — is shown as it came, never dropped and never an
|
|
74
|
+
* exception; '' when there is none.
|
|
75
|
+
*/
|
|
76
|
+
export function formatTerritorialScope(lang: Lang, scope: string | null | undefined): string {
|
|
77
|
+
if (typeof scope !== 'string') return ''
|
|
78
|
+
const value = scope.trim()
|
|
79
|
+
if (value === 'all') {
|
|
80
|
+
const t = translator(lang)
|
|
81
|
+
return t('statement.scopeAll')
|
|
82
|
+
}
|
|
83
|
+
if (!REGION_LIST.test(value)) return value
|
|
84
|
+
const names = value.split(',').map((code) => regionName(lang, code))
|
|
85
|
+
try {
|
|
86
|
+
return cached(regionLists, lang, (locale) => new Intl.ListFormat(locale, { type: 'conjunction' }))?.format(names) ?? names.join(', ')
|
|
87
|
+
} catch {
|
|
88
|
+
return names.join(', ')
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/core/i18n.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The elements' language and their texts (m1-contracts.md §10.4): every string a reader sees comes from
|
|
3
|
+
* the SDK's catalogue, `communityMessage` — one catalogue, one tone test. The elements add no words of
|
|
4
|
+
* their own; test/text-only.spec.ts scans src/ for any.
|
|
5
|
+
*/
|
|
6
|
+
import { communityMessage, hasCommunityMessage, type CommunityMessageKey } from '@blogmcp/sdk/community'
|
|
7
|
+
|
|
8
|
+
export type Lang = 'cs' | 'en'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The review layer's rule: the first candidate that is Czech or Slovak reads Czech, the first that is
|
|
12
|
+
* English reads English; anything else is passed over; English when none decides. The candidates are
|
|
13
|
+
* the `lang` attribute, then `<html lang>`.
|
|
14
|
+
*/
|
|
15
|
+
export function pickLang(...candidates: Array<string | null | undefined>): Lang {
|
|
16
|
+
for (const c of candidates) {
|
|
17
|
+
if (!c) continue
|
|
18
|
+
const l = c.toLowerCase()
|
|
19
|
+
if (l.startsWith('cs') || l.startsWith('sk')) return 'cs'
|
|
20
|
+
if (l.startsWith('en')) return 'en'
|
|
21
|
+
}
|
|
22
|
+
return 'en'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The page's own language attribute, when there is a page (the elements mount in a browser only). */
|
|
26
|
+
export function documentLang(): string | null {
|
|
27
|
+
return typeof document === 'undefined' ? null : document.documentElement.lang || null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type TextParams = Record<string, string | number>
|
|
31
|
+
export type Translate = (key: CommunityMessageKey, params?: TextParams) => string
|
|
32
|
+
|
|
33
|
+
export function translator(lang: Lang): Translate {
|
|
34
|
+
return (key, params) => communityMessage(lang, key, params)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A key built from one of kb-api's open codes (`restriction.<code>`, `caseStatus.<status>`, …) when the
|
|
39
|
+
* catalogue has a text for it, else null: a code a newer kb-api sends is skipped, never shown raw.
|
|
40
|
+
*/
|
|
41
|
+
export function knownKey(key: string): CommunityMessageKey | null {
|
|
42
|
+
return hasCommunityMessage(key) ? key : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A message key and its params: what a controller hands the view, which turns it into text. */
|
|
46
|
+
export interface TextRef {
|
|
47
|
+
readonly key: CommunityMessageKey
|
|
48
|
+
readonly params?: TextParams
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names in the onboarding and the profile form, checked the way kb-api checks them before a request goes
|
|
3
|
+
* (m1-contracts.md §7.4.3): the display name NFKC-folded, 2–80 characters; a handle `COMMUNITY_HANDLE_RE`.
|
|
4
|
+
* kb-api decides the rest (reserved words, look-alikes, clashes) and the element shows its answer.
|
|
5
|
+
*/
|
|
6
|
+
import { COMMUNITY_DISPLAY_NAME_MAX, COMMUNITY_DISPLAY_NAME_MIN, COMMUNITY_HANDLE_RE } from '@blogmcp/sdk/community'
|
|
7
|
+
import { codePointLength } from './text'
|
|
8
|
+
|
|
9
|
+
/** The display name as kb-api will fold it: NFKC, white space collapsed, trimmed. */
|
|
10
|
+
export function normalizeName(name: string): string {
|
|
11
|
+
return typeof name === 'string' ? name.normalize('NFKC').replace(/\s+/gu, ' ').trim() : ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isDisplayNameOk(name: string): boolean {
|
|
15
|
+
const n = codePointLength(normalizeName(name))
|
|
16
|
+
return n >= COMMUNITY_DISPLAY_NAME_MIN && n <= COMMUNITY_DISPLAY_NAME_MAX
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A handle's length as COMMUNITY_HANDLE_RE takes it (3–30), for the hint and the field's maxlength. */
|
|
20
|
+
export const HANDLE_MIN = 3
|
|
21
|
+
export const HANDLE_MAX = 30
|
|
22
|
+
|
|
23
|
+
/** A handle as typed: lower case, trimmed; '' when none was typed. */
|
|
24
|
+
export function normalizeHandle(handle: string): string {
|
|
25
|
+
return typeof handle === 'string' ? handle.trim().toLowerCase() : ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isHandleOk(handle: string): boolean {
|
|
29
|
+
const h = normalizeHandle(handle)
|
|
30
|
+
return h === '' || COMMUNITY_HANDLE_RE.test(h)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Letters NFD does not take apart, as kb-api's stem maps them. */
|
|
34
|
+
const ASCII: Readonly<Record<string, string>> = Object.freeze({ ß: 'ss', ł: 'l', đ: 'd', ð: 'd', ø: 'o', ħ: 'h', ŧ: 't', ı: 'i', æ: 'ae', œ: 'oe', þ: 'th', ŀ: 'l' })
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The handle kb-api would make of a display name when the member gives none (its stem: fold → ASCII →
|
|
38
|
+
* words joined by `_`, at most 24 characters) — shown as a suggestion; null when the name gives too little.
|
|
39
|
+
* kb-api may still add a number on a clash, or leave out a reserved word.
|
|
40
|
+
*/
|
|
41
|
+
export function suggestedHandle(displayName: string): string | null {
|
|
42
|
+
const folded = normalizeName(displayName).toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '').normalize('NFC')
|
|
43
|
+
const ascii = folded.replace(/[ßłđðøħŧıæœþŀ]/g, (ch) => ASCII[ch] ?? ch)
|
|
44
|
+
const stem = ascii
|
|
45
|
+
.split(/[^a-z0-9]+/)
|
|
46
|
+
.filter((p) => p !== '')
|
|
47
|
+
.join('_')
|
|
48
|
+
.slice(0, 24)
|
|
49
|
+
.replace(/_+$/, '')
|
|
50
|
+
return COMMUNITY_HANDLE_RE.test(stem) ? stem : null
|
|
51
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<blogmcp-notice-form>`'s brain (m1-contracts.md §7.7, §10.4; community.md §5; DSA art. 16): the notice
|
|
3
|
+
* anyone may send about a message, a profile or an article — with or without a session. The category, the
|
|
4
|
+
* description, the optional name and e-mail (hidden and never sent for `child_at_risk`: kb-api refuses a
|
|
5
|
+
* contact there, NOTIFIER_NOT_ALLOWED), the Turnstile token, and the good-faith statement of art. 16(2)(d)
|
|
6
|
+
* (`confirmations: ['good_faith']`, §7.14 I-2). The answer is the notifier's receipt: the status link carries
|
|
7
|
+
* it in its fragment only.
|
|
8
|
+
*/
|
|
9
|
+
import { reactive } from 'vue'
|
|
10
|
+
import {
|
|
11
|
+
CATEGORY_WITHOUT_NOTIFIER,
|
|
12
|
+
COMMUNITY_GOOD_FAITH,
|
|
13
|
+
COMMUNITY_NOTICE_DESCRIPTION_MAX,
|
|
14
|
+
COMMUNITY_NOTIFIER_NAME_MAX,
|
|
15
|
+
communityRulesOf,
|
|
16
|
+
noticeStatusLink,
|
|
17
|
+
type CaseCategory,
|
|
18
|
+
type CommunityCallOptions,
|
|
19
|
+
type CommunityClient,
|
|
20
|
+
type CommunityFailure,
|
|
21
|
+
type CommunityRule,
|
|
22
|
+
type NoticeReceipt,
|
|
23
|
+
type NoticeSubmitInput,
|
|
24
|
+
type NoticeTarget,
|
|
25
|
+
} from '@blogmcp/sdk/community'
|
|
26
|
+
import type { Lang } from './i18n'
|
|
27
|
+
import { codePointLength, normalizeBody } from './text'
|
|
28
|
+
|
|
29
|
+
/** `off`: no site key (development, a host without the widget); kb-api then decides alone. */
|
|
30
|
+
export type CaptchaState = 'off' | 'loading' | 'ready' | 'unavailable'
|
|
31
|
+
|
|
32
|
+
export interface NoticeFormUi {
|
|
33
|
+
target: NoticeTarget | null
|
|
34
|
+
category: CaseCategory | ''
|
|
35
|
+
ruleId: string
|
|
36
|
+
rules: CommunityRule[] | null
|
|
37
|
+
description: string
|
|
38
|
+
name: string
|
|
39
|
+
email: string
|
|
40
|
+
goodFaith: boolean
|
|
41
|
+
captcha: CaptchaState
|
|
42
|
+
/** The widget's one-shot token, or null. */
|
|
43
|
+
token: string | null
|
|
44
|
+
/** Bumped after every failed send: the widget issues a new token. */
|
|
45
|
+
resetSignal: number
|
|
46
|
+
sending: boolean
|
|
47
|
+
failure: CommunityFailure | null
|
|
48
|
+
receipt: NoticeReceipt | null
|
|
49
|
+
/** The notifier's status page with the receipt in the fragment. */
|
|
50
|
+
statusLink: string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NoticeFormController {
|
|
54
|
+
readonly state: NoticeFormUi
|
|
55
|
+
/** The name and e-mail fields apply (not for an endangered child). */
|
|
56
|
+
contactAllowed(): boolean
|
|
57
|
+
canSubmit(): boolean
|
|
58
|
+
descriptionLength(): number
|
|
59
|
+
setCategory(category: CaseCategory | ''): void
|
|
60
|
+
setToken(token: string | null): void
|
|
61
|
+
setCaptcha(state: CaptchaState): void
|
|
62
|
+
submit(): Promise<void>
|
|
63
|
+
dispose(): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface NoticeFormOptions {
|
|
67
|
+
client: Pick<CommunityClient, 'notices' | 'legal'>
|
|
68
|
+
lang: Lang
|
|
69
|
+
target: NoticeTarget | null
|
|
70
|
+
/** A Turnstile site key was given: a token is needed before the form can be sent. */
|
|
71
|
+
captcha: boolean
|
|
72
|
+
/** The host's status page (the receipt goes in its fragment). */
|
|
73
|
+
noticesPath: string
|
|
74
|
+
/** The form was sent (the receipt is the page's: it is not passed on). */
|
|
75
|
+
onSubmitted?(answer: { contactKept: boolean }): void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createNoticeForm(o: NoticeFormOptions): NoticeFormController {
|
|
79
|
+
const abort = new AbortController()
|
|
80
|
+
const call: CommunityCallOptions = { signal: abort.signal }
|
|
81
|
+
const state = reactive<NoticeFormUi>({
|
|
82
|
+
target: o.target,
|
|
83
|
+
category: '',
|
|
84
|
+
ruleId: '',
|
|
85
|
+
rules: null,
|
|
86
|
+
description: '',
|
|
87
|
+
name: '',
|
|
88
|
+
email: '',
|
|
89
|
+
goodFaith: false,
|
|
90
|
+
captcha: o.captcha ? 'loading' : 'off',
|
|
91
|
+
token: null,
|
|
92
|
+
resetSignal: 0,
|
|
93
|
+
sending: false,
|
|
94
|
+
failure: null,
|
|
95
|
+
receipt: null,
|
|
96
|
+
statusLink: null,
|
|
97
|
+
}) as NoticeFormUi
|
|
98
|
+
let rulesAsked = false
|
|
99
|
+
|
|
100
|
+
const contactAllowed = (): boolean => state.category !== CATEGORY_WITHOUT_NOTIFIER
|
|
101
|
+
const descriptionLength = (): number => codePointLength(normalizeBody(state.description))
|
|
102
|
+
|
|
103
|
+
function canSubmit(): boolean {
|
|
104
|
+
const length = descriptionLength()
|
|
105
|
+
return (
|
|
106
|
+
!state.sending &&
|
|
107
|
+
state.receipt === null &&
|
|
108
|
+
state.target !== null &&
|
|
109
|
+
state.category !== '' &&
|
|
110
|
+
length > 0 &&
|
|
111
|
+
length <= COMMUNITY_NOTICE_DESCRIPTION_MAX &&
|
|
112
|
+
codePointLength(state.name.trim()) <= COMMUNITY_NOTIFIER_NAME_MAX &&
|
|
113
|
+
state.goodFaith &&
|
|
114
|
+
(state.captcha === 'off' || (state.captcha === 'ready' && state.token !== null))
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function loadRules(): void {
|
|
119
|
+
if (rulesAsked) return
|
|
120
|
+
rulesAsked = true
|
|
121
|
+
void o.client.legal.current({ kind: 'rules', locale: o.lang }, call).then((r) => {
|
|
122
|
+
state.rules = r.ok ? communityRulesOf(r.value.bodyJson) : []
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function submit(): Promise<void> {
|
|
127
|
+
if (!canSubmit() || !state.target || state.category === '') return
|
|
128
|
+
state.sending = true
|
|
129
|
+
state.failure = null
|
|
130
|
+
const input: NoticeSubmitInput = {
|
|
131
|
+
category: state.category,
|
|
132
|
+
target: state.target,
|
|
133
|
+
description: normalizeBody(state.description),
|
|
134
|
+
confirmations: [COMMUNITY_GOOD_FAITH],
|
|
135
|
+
}
|
|
136
|
+
if (state.category === 'rules' && state.ruleId !== '') input.ruleId = state.ruleId
|
|
137
|
+
// An endangered child is reported without the notifier's name and e-mail: not even sent.
|
|
138
|
+
const name = state.name.trim()
|
|
139
|
+
const email = state.email.trim()
|
|
140
|
+
if (contactAllowed() && (name !== '' || email !== '')) input.notifier = { name: name || null, email: email || null, locale: o.lang }
|
|
141
|
+
if (state.token !== null) input.turnstileToken = state.token
|
|
142
|
+
// The token is spent by this request whatever its answer.
|
|
143
|
+
state.token = null
|
|
144
|
+
const r = await o.client.notices.submit(input, call)
|
|
145
|
+
state.sending = false
|
|
146
|
+
if (r.ok) {
|
|
147
|
+
state.receipt = r.value
|
|
148
|
+
state.statusLink = noticeStatusLink(o.noticesPath, r.value.receipt)
|
|
149
|
+
o.onSubmitted?.({ contactKept: r.value.contactKept })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
state.failure = r
|
|
153
|
+
if (state.captcha !== 'off') state.resetSignal += 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
state,
|
|
158
|
+
contactAllowed,
|
|
159
|
+
canSubmit,
|
|
160
|
+
descriptionLength,
|
|
161
|
+
setCategory(category) {
|
|
162
|
+
state.category = category
|
|
163
|
+
if (category !== 'rules') state.ruleId = ''
|
|
164
|
+
else loadRules()
|
|
165
|
+
},
|
|
166
|
+
setToken(token) {
|
|
167
|
+
state.token = token
|
|
168
|
+
if (token !== null && state.captcha === 'loading') state.captcha = 'ready'
|
|
169
|
+
},
|
|
170
|
+
setCaptcha(captcha) {
|
|
171
|
+
if (state.captcha !== 'off') state.captcha = captcha
|
|
172
|
+
},
|
|
173
|
+
submit,
|
|
174
|
+
dispose() {
|
|
175
|
+
abort.abort()
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|