@blogmcp/community 0.1.0 → 0.3.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.
Files changed (114) hide show
  1. package/README.md +162 -13
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/mount--2_V2q3M.js +10063 -0
  4. package/dist/core/attrs.d.ts +42 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +120 -15
  9. package/dist/core/follow.d.ts +26 -0
  10. package/dist/core/format.d.ts +7 -0
  11. package/dist/core/forum-category.d.ts +85 -0
  12. package/dist/core/forum-search.d.ts +35 -0
  13. package/dist/core/forum-state.d.ts +30 -0
  14. package/dist/core/forum-words.d.ts +8 -0
  15. package/dist/core/forum.d.ts +21 -0
  16. package/dist/core/moderation.d.ts +80 -0
  17. package/dist/core/onboarding.d.ts +49 -2
  18. package/dist/core/outcomes.d.ts +2 -1
  19. package/dist/core/panel.d.ts +17 -1
  20. package/dist/core/profile.d.ts +45 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +46 -0
  23. package/dist/core/settings.d.ts +53 -0
  24. package/dist/core/text.d.ts +13 -0
  25. package/dist/core/topic.d.ts +47 -0
  26. package/dist/element.d.ts +47 -1
  27. package/dist/global.d.ts +8 -4
  28. package/dist/index.d.ts +20 -3
  29. package/dist/index.js +62 -12
  30. package/dist/mount.d.ts +89 -0
  31. package/dist/react.d.ts +27 -3
  32. package/dist/react.js +48 -4
  33. package/dist/ui/BadgeView.vue.d.ts +13 -0
  34. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  35. package/dist/ui/FollowButtonView.vue.d.ts +17 -0
  36. package/dist/ui/FollowToggle.vue.d.ts +6 -0
  37. package/dist/ui/ForumCategoryView.vue.d.ts +13 -0
  38. package/dist/ui/ForumModerationView.vue.d.ts +12 -0
  39. package/dist/ui/ForumNotices.vue.d.ts +7 -0
  40. package/dist/ui/ForumSearchView.vue.d.ts +11 -0
  41. package/dist/ui/ForumView.vue.d.ts +12 -0
  42. package/dist/ui/LinkedText.vue.d.ts +1 -0
  43. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  44. package/dist/ui/MembersOnly.vue.d.ts +15 -0
  45. package/dist/ui/MessageComposer.vue.d.ts +8 -2
  46. package/dist/ui/MessageMenu.vue.d.ts +2 -0
  47. package/dist/ui/ModeratorDialog.vue.d.ts +11 -0
  48. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  49. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  50. package/dist/ui/ProfileView.vue.d.ts +13 -0
  51. package/dist/ui/TopicRow.vue.d.ts +10 -0
  52. package/dist/ui/TopicView.vue.d.ts +32 -0
  53. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  54. package/dist/ui/context.d.ts +7 -1
  55. package/dist/ui/discussion-context.d.ts +11 -3
  56. package/dist/vue.d.ts +273 -3
  57. package/dist/vue.js +235 -5
  58. package/package.json +3 -3
  59. package/src/core/attrs.ts +61 -2
  60. package/src/core/badge.ts +120 -0
  61. package/src/core/centre.ts +136 -0
  62. package/src/core/digest-token.ts +26 -0
  63. package/src/core/discussion.ts +497 -46
  64. package/src/core/follow.ts +139 -0
  65. package/src/core/format.ts +22 -0
  66. package/src/core/forum-category.ts +323 -0
  67. package/src/core/forum-search.ts +153 -0
  68. package/src/core/forum-state.ts +59 -0
  69. package/src/core/forum-words.ts +38 -0
  70. package/src/core/forum.ts +83 -0
  71. package/src/core/moderation.ts +414 -0
  72. package/src/core/onboarding.ts +177 -18
  73. package/src/core/outcomes.ts +23 -1
  74. package/src/core/panel.ts +39 -4
  75. package/src/core/profile.ts +155 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +52 -0
  78. package/src/core/settings.ts +363 -0
  79. package/src/core/text.ts +32 -0
  80. package/src/core/topic.ts +234 -0
  81. package/src/element.ts +124 -6
  82. package/src/global.ts +40 -4
  83. package/src/index.ts +84 -2
  84. package/src/mount.ts +250 -1
  85. package/src/react.ts +90 -3
  86. package/src/ui/BadgeView.vue +51 -0
  87. package/src/ui/DiscussionView.vue +43 -3
  88. package/src/ui/FollowButtonView.vue +85 -0
  89. package/src/ui/FollowToggle.vue +39 -0
  90. package/src/ui/ForumCategoryView.vue +243 -0
  91. package/src/ui/ForumModerationView.vue +309 -0
  92. package/src/ui/ForumNotices.vue +23 -0
  93. package/src/ui/ForumSearchView.vue +171 -0
  94. package/src/ui/ForumView.vue +128 -0
  95. package/src/ui/LinkedText.vue +11 -5
  96. package/src/ui/MemberSettingsView.vue +175 -0
  97. package/src/ui/MembersOnly.vue +59 -0
  98. package/src/ui/MessageComposer.vue +64 -11
  99. package/src/ui/MessageItem.vue +36 -7
  100. package/src/ui/MessageMenu.vue +3 -2
  101. package/src/ui/ModeratorDialog.vue +102 -0
  102. package/src/ui/MyCommunityView.vue +12 -0
  103. package/src/ui/NoticeStatusView.vue +15 -2
  104. package/src/ui/NotificationCentreView.vue +193 -0
  105. package/src/ui/OnboardingSteps.vue +17 -6
  106. package/src/ui/ProfileView.vue +102 -0
  107. package/src/ui/TopicRow.vue +70 -0
  108. package/src/ui/TopicView.vue +395 -0
  109. package/src/ui/WelcomeView.vue +72 -0
  110. package/src/ui/context.ts +8 -3
  111. package/src/ui/discussion-context.ts +11 -3
  112. package/src/ui/styles.css +30 -0
  113. package/src/vue.ts +271 -3
  114. package/dist/chunks/mount-DzjwnHyx.js +0 -4597
@@ -3,23 +3,41 @@
3
3
  * The composer of a new message, a reply or an edit: 4 000 characters with a counter (announced near the
4
4
  * end), the links note, a link to the rules (community.md §4.5: by every composer; a new tab, so the text
5
5
  * stays), Cmd/Ctrl+Enter, the cooldown after TOO_FAST or a quota, and the Russmedia confirmation when kb-api
6
- * asks for one (the same text goes again with `confirmations`).
6
+ * asks for one (the same text goes again with `confirmations`). M2 T10: with the member's mention cap
7
+ * (`mentionsMax`, me/get's `caps.mentionsPerMessage`), a draft that mentions more distinct `@handle`s says so as it
8
+ * is typed (`error.mentionLimit`) and is not sent; kb-api's MENTION_LIMIT stays the rule.
9
+ *
10
+ * M3 (m3-contracts.md §3.3, §5.3; B21): a draft with a `title` (a string) asks for a topic's title too — a new topic
11
+ * (`kind` `topic`, its body the opening post) or its author's edit of the opening post — 1 to 200 UTF-16 units, as
12
+ * kb-api counts them (forumTopicTitleOf); the link to the rules stays, as on every composer.
7
13
  */
8
14
  import { computed, onBeforeUnmount, ref, watch } from 'vue'
9
- import { COMMUNITY_MESSAGE_LINKS_MAX, COMMUNITY_MESSAGE_MAX, confirmMessageKey } from '@blogmcp/sdk/community'
15
+ import {
16
+ COMMUNITY_MESSAGE_LINKS_MAX,
17
+ COMMUNITY_MESSAGE_MAX,
18
+ COMMUNITY_TOPIC_TITLE_MAX,
19
+ communityMentionHandles,
20
+ confirmMessageKey,
21
+ forumTopicTitleOf,
22
+ topicTitleLength,
23
+ } from '@blogmcp/sdk/community'
10
24
  import type { Draft } from '../core/discussion'
11
25
  import { COUNTDOWN_MAX_S, failureText } from '../core/outcomes'
12
26
  import { codePointLength, normalizeBody } from '../core/text'
13
27
  import { uid, useText } from './context'
14
28
 
15
29
  const props = defineProps<{
16
- draft: Draft
17
- kind: 'new' | 'reply' | 'edit'
30
+ /** M3: `title` (a string) asks for a topic's title too; null or absent: no title. */
31
+ draft: Draft & { title?: string | null }
32
+ /** M3: `topic` — a new topic (the title and its opening post). */
33
+ kind: 'new' | 'reply' | 'edit' | 'topic'
18
34
  /** Seconds the draft still waits (the controller's clock). */
19
35
  wait: number
20
36
  replyingTo?: string | null
21
37
  /** The host's rules page. */
22
38
  rulesHref?: string
39
+ /** M2 T10: the distinct mentions one message may carry; null or absent: no limit known here. */
40
+ mentionsMax?: number | null
23
41
  }>()
24
42
  const emit = defineEmits<{ submit: []; cancel: []; confirmed: []; confirm: [code: string, checked: boolean] }>()
25
43
  const { t, lang } = useText()
@@ -28,17 +46,34 @@ const id = uid('bcm-composer')
28
46
  const input = ref<HTMLTextAreaElement | null>(null)
29
47
  const length = computed(() => codePointLength(normalizeBody(props.draft.body)))
30
48
  const over = computed(() => length.value > COMMUNITY_MESSAGE_MAX)
31
- const canSend = computed(() => !props.draft.sending && length.value > 0 && !over.value && props.wait === 0 && props.draft.confirm === null)
49
+ const mentionsOver = computed(() => {
50
+ const max = props.mentionsMax
51
+ return typeof max === 'number' && max >= 0 && communityMentionHandles(normalizeBody(props.draft.body)).length > max
52
+ })
53
+ /** M3: the draft asks for a topic's title. */
54
+ const titled = computed(() => typeof props.draft.title === 'string')
55
+ const titleLength = computed(() => topicTitleLength(props.draft.title))
56
+ const titleOver = computed(() => titled.value && titleLength.value > COMMUNITY_TOPIC_TITLE_MAX)
57
+ const titleOk = computed(() => !titled.value || forumTopicTitleOf(props.draft.title) !== null)
58
+ /** The post is written and the title is still blank: said where the title goes (the button waits for it). */
59
+ const titleMissing = computed(() => titled.value && !titleOver.value && !titleOk.value && length.value > 0)
60
+ const canSend = computed(
61
+ () => !props.draft.sending && length.value > 0 && !over.value && !mentionsOver.value && titleOk.value && props.wait === 0 && props.draft.confirm === null,
62
+ )
32
63
  const allConfirmed = computed(() => {
33
64
  const c = props.draft.confirm
34
65
  return c !== null && c.codes.every((code) => c.checked.includes(code))
35
66
  })
36
- const label = computed(() => t(props.kind === 'reply' ? 'composer.replyLabel' : props.kind === 'edit' ? 'composer.editLabel' : 'composer.label'))
67
+ const label = computed(() =>
68
+ t(props.kind === 'reply' ? 'composer.replyLabel' : props.kind === 'edit' ? 'composer.editLabel' : props.kind === 'topic' ? 'topic.bodyLabel' : 'composer.label'),
69
+ )
37
70
  const keys = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent) ? '⌘ + Enter' : 'Ctrl + Enter'
38
71
  const failure = computed(() => {
39
72
  const f = props.draft.failure
40
73
  // The confirmation box says what CONFIRMATION_REQUIRED meant; any later refusal (the confirmed send's) shows.
41
74
  if (!f || (props.draft.confirm && f.code === 'confirm')) return ''
75
+ // The mention warning below says it already, as long as the draft is over the cap.
76
+ if (f.code === 'mentionLimit' && mentionsOver.value) return ''
42
77
  // A short wait reads as the countdown instead.
43
78
  if ((f.code === 'fast' || f.code === 'quota') && props.wait > 0 && props.wait <= COUNTDOWN_MAX_S) return ''
44
79
  const ref = failureText(f, lang.value)
@@ -59,9 +94,9 @@ onBeforeUnmount(() => {
59
94
  })
60
95
 
61
96
  // A changed text is a new attempt: the old refusal and the confirmation it asked for no longer apply
62
- // (a cooldown still does).
97
+ // (a cooldown still does). M3: the title is part of the text.
63
98
  watch(
64
- () => props.draft.body,
99
+ () => [props.draft.body, props.draft.title] as const,
65
100
  () => {
66
101
  const d = props.draft
67
102
  if (d.confirm) d.confirm = null
@@ -84,6 +119,23 @@ defineExpose({ focus: () => input.value?.focus() })
84
119
 
85
120
  <template>
86
121
  <form class="bcm-composer" @submit.prevent="onSubmit">
122
+ <template v-if="titled">
123
+ <label :for="`${id}-title`" class="bcm-label">{{ t('topic.titleLabel') }}</label>
124
+ <input
125
+ :id="`${id}-title`"
126
+ v-model="draft.title"
127
+ class="bcm-input"
128
+ type="text"
129
+ autocomplete="off"
130
+ :aria-describedby="`${id}-title-counter`"
131
+ :aria-invalid="titleOver"
132
+ :readonly="draft.sending"
133
+ @keydown="onKey"
134
+ />
135
+ <span :id="`${id}-title-counter`" class="bcm-counter" :class="{ 'bcm-counter-over': titleOver }">{{ t('composer.counter', { length: titleLength, max: COMMUNITY_TOPIC_TITLE_MAX }) }}</span>
136
+ <p v-if="titleOver" class="bcm-error">{{ t('topic.titleTooLong', { max: COMMUNITY_TOPIC_TITLE_MAX }) }}</p>
137
+ <p v-else-if="titleMissing" class="bcm-hint">{{ t('topic.titleMissing') }}</p>
138
+ </template>
87
139
  <label :for="id" class="bcm-label">{{ label }}</label>
88
140
  <p v-if="replyingTo" class="bcm-hint">{{ t('composer.replyingTo', { name: replyingTo }) }}</p>
89
141
  <textarea
@@ -94,7 +146,7 @@ defineExpose({ focus: () => input.value?.focus() })
94
146
  rows="4"
95
147
  :placeholder="t('composer.placeholder')"
96
148
  :aria-describedby="`${id}-counter ${id}-hints`"
97
- :aria-invalid="over"
149
+ :aria-invalid="over || mentionsOver"
98
150
  :readonly="draft.sending"
99
151
  @keydown="onKey"
100
152
  />
@@ -102,9 +154,9 @@ defineExpose({ focus: () => input.value?.focus() })
102
154
  <span :id="`${id}-counter`" class="bcm-counter" :class="{ 'bcm-counter-over': over }">{{ t('composer.counter', { length, max: COMMUNITY_MESSAGE_MAX }) }}</span>
103
155
  <span class="bcm-sr" aria-live="polite">{{ counterLive }}</span>
104
156
  <span class="bcm-spacer" />
105
- <button v-if="kind !== 'new'" type="button" class="bcm-btn" :disabled="draft.sending" @click="emit('cancel')">{{ t('composer.cancel') }}</button>
157
+ <button v-if="kind !== 'new' && kind !== 'topic'" type="button" class="bcm-btn" :disabled="draft.sending" @click="emit('cancel')">{{ t('composer.cancel') }}</button>
106
158
  <button type="submit" class="bcm-btn bcm-primary" :disabled="!canSend">
107
- {{ draft.sending ? t('composer.sending') : kind === 'edit' ? t('composer.save') : t('composer.submit') }}
159
+ {{ draft.sending ? t('composer.sending') : kind === 'edit' ? t('composer.save') : kind === 'topic' ? t('topic.create') : t('composer.submit') }}
108
160
  </button>
109
161
  </div>
110
162
  <div :id="`${id}-hints`" class="bcm-hint">
@@ -113,6 +165,7 @@ defineExpose({ focus: () => input.value?.focus() })
113
165
  <p v-if="rulesHref"><a :href="rulesHref" target="_blank" rel="noopener">{{ t('onboarding.rulesTitle') }}</a></p>
114
166
  </div>
115
167
  <p v-if="over" class="bcm-error">{{ t('composer.tooLong', { max: COMMUNITY_MESSAGE_MAX }) }}</p>
168
+ <p v-if="mentionsOver" class="bcm-error" role="alert">{{ t('error.mentionLimit') }}</p>
116
169
  <p v-if="wait > 0 && wait <= COUNTDOWN_MAX_S" class="bcm-warning" role="status">{{ t('composer.cooldown', { count: wait }) }}</p>
117
170
  <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
118
171
  <fieldset v-if="draft.confirm" class="bcm-confirm">
@@ -11,9 +11,14 @@
11
11
  * and while the discussion asks this member for the consent it offers the onboarding's consent step in place
12
12
  * (`beginOnboarding`, the composer's own). kb-api sends the flag on the author's read only, so another reader sees
13
13
  * the plain placeholder.
14
+ *
15
+ * M3 (m3-contracts.md §3.5, §4.5, §5.3): under a topic — „Moderátor" beside a member's message in a category the member
16
+ * moderates now (`author.moderator`, the only place the label shows), the topic's title in its author's edit of the
17
+ * opening post, and a category moderator's items in the menu (the topic's context provides them). A message waiting in
18
+ * pre-moderation reads „Čeká na kontrolu" to its author, as any held one.
14
19
  */
15
20
  import { computed } from 'vue'
16
- import { hasCommunityMessage, noteMessageKey, warningMessageKey, type CommunityMessageKey, type DiscussionMessage } from '@blogmcp/sdk/community'
21
+ import { communityMessageAnchor, hasCommunityMessage, noteMessageKey, warningMessageKey, type CommunityMessageKey, type DiscussionMessage } from '@blogmcp/sdk/community'
17
22
  import { reportLink } from '../core/attrs'
18
23
  import { formatDate } from '../core/format'
19
24
  import AuthorAvatar from './AuthorAvatar.vue'
@@ -29,7 +34,7 @@ defineOptions({ name: 'MessageItem' })
29
34
 
30
35
  const props = defineProps<{ message: DiscussionMessage }>()
31
36
  const { t, lang } = useText()
32
- const { ctl, routes, opened, originals, allReplies, deleteMessage } = useDiscussion()
37
+ const { ctl, routes, opened, originals, allReplies, deleteMessage, extraMenu, onExtraMenu } = useDiscussion()
33
38
 
34
39
  /** Replies shown before „Zobrazit další odpovědi". */
35
40
  const REPLIES_SHOWN = 3
@@ -40,9 +45,18 @@ const own = computed(() => ctl.isOwn(m.value))
40
45
  const placeholder = computed(() => m.value.body === null)
41
46
  /** The author's own message held back from others until their art. 9 consent (P-RV3) — not one awaiting review. */
42
47
  const untilConsent = computed(() => own.value && m.value.state === 'held' && m.value.hiddenUntilConsent === true)
43
- /** The consent step can be opened from here: the discussion asks this member for it, and the onboarding is not open yet. */
48
+ /**
49
+ * The consent step can be opened from here: the discussion asks this member for it, or the member refused it where the
50
+ * tenant leaves the public discussion open (`acceptances.art9Refused`, M2 T9: needs name no consent then, yet the post
51
+ * stays hidden from others until it is given) — and the onboarding is not open yet.
52
+ */
53
+ const refusedConsent = computed(() => ctl.state.me?.acceptances.art9Refused === true)
44
54
  const canGiveConsent = computed(
45
- () => untilConsent.value && ctl.mode() === 'onboarding' && ctl.needs().art9 && !ctl.onboarding.value && ctl.state.refusedAge === null,
55
+ () =>
56
+ untilConsent.value &&
57
+ !ctl.onboarding.value &&
58
+ ctl.state.refusedAge === null &&
59
+ ((ctl.mode() === 'onboarding' && ctl.needs().art9) || refusedConsent.value),
46
60
  )
47
61
 
48
62
  const authorName = computed(() => {
@@ -52,6 +66,8 @@ const authorName = computed(() => {
52
66
  return a.displayName ?? t('author.deleted')
53
67
  })
54
68
  const handle = computed(() => (m.value.author.kind === 'member' ? m.value.author.handle : null))
69
+ /** M3 (D-M3-7, §4.5): an appointment acting now, in this topic's category — never a trust level. */
70
+ const moderatorLabel = computed(() => m.value.author.kind === 'member' && 'moderator' in m.value.author && m.value.author.moderator === true)
55
71
  const badge = computed(() => {
56
72
  const a = m.value.author
57
73
  if (a.kind !== 'staff') return null
@@ -62,6 +78,8 @@ const badge = computed(() => {
62
78
  const placeholderKey = computed<CommunityMessageKey>(() =>
63
79
  m.value.state === 'held' ? 'discussion.placeholderHeld' : m.value.state === 'removed' ? 'discussion.placeholderRemoved' : 'discussion.placeholderDeleted',
64
80
  )
81
+ /** M2 T10: the row's anchor (`bcm-msg-<seq>`), the fragment the notification centre's links carry; none while pending. */
82
+ const anchorId = computed(() => (m.value.pending ? undefined : (communityMessageAnchor(m.value.seq) ?? undefined)))
65
83
  const hiddenByBlock = computed(() => m.value.collapsed && !opened.has(m.value.seq))
66
84
  const showOriginal = computed(() => originals.has(m.value.seq))
67
85
  const text = computed(() => (m.value.isTranslated && !showOriginal.value ? m.value.displayBody : m.value.body) ?? m.value.displayBody)
@@ -78,6 +96,11 @@ const shownReplies = computed(() => (allReplies.has(m.value.seq) ? m.value.repli
78
96
  const moreReplies = computed(() => m.value.replies.length - shownReplies.value.length)
79
97
 
80
98
  const target = computed(() => ({ conversationId: m.value.conversationId, seq: m.value.seq }))
99
+ /**
100
+ * A mention links to the member's profile for a viewer who may see profiles (a signed-in host session, m2-contracts.md
101
+ * §3.3: "never a hyperlink to a profile for a member who cannot see it"); an anonymous reader reads it as text.
102
+ */
103
+ const mentionProfiles = computed(() => (ctl.needs().signIn ? null : routes.value.profilePath))
81
104
 
82
105
  const menu = computed<MenuItem[]>(() => {
83
106
  const items: MenuItem[] = []
@@ -90,6 +113,8 @@ const menu = computed<MenuItem[]>(() => {
90
113
  if (href) items.push({ id: 'report', label: t('discussion.report'), href })
91
114
  }
92
115
  if (ctl.canBlock(msg)) items.push({ id: msg.collapsed ? 'unblock' : 'block', label: msg.collapsed ? t('discussion.unblock') : t('discussion.block') })
116
+ // M3: a category moderator's items (the topic's context names them; none elsewhere).
117
+ if (extraMenu) items.push(...extraMenu(msg))
93
118
  return items
94
119
  })
95
120
 
@@ -99,6 +124,7 @@ function onMenu(id: string): void {
99
124
  else if (id === 'delete') deleteMessage(msg.seq)
100
125
  else if (id === 'report') ctl.openReport(msg)
101
126
  else if ((id === 'block' || id === 'unblock') && msg.author.kind === 'member' && msg.author.memberId) void ctl.setBlocked(msg.author.memberId, id === 'block')
127
+ else onExtraMenu?.(id, msg)
102
128
  }
103
129
 
104
130
  function reply(): void {
@@ -112,13 +138,14 @@ function toggle(set: Set<number>): void {
112
138
  </script>
113
139
 
114
140
  <template>
115
- <li class="bcm-msg-item">
141
+ <li :id="anchorId" class="bcm-msg-item">
116
142
  <article class="bcm-msg" :class="{ 'bcm-msg-pending': !!m.pending }" :aria-busy="!!m.pending">
117
143
  <AuthorAvatar :author="m.author" />
118
144
  <div class="bcm-msg-main">
119
145
  <header class="bcm-meta">
120
146
  <span class="bcm-author">{{ authorName }}</span>
121
147
  <span v-if="badge" class="bcm-badge bcm-badge-team">{{ badge }}</span>
148
+ <span v-if="moderatorLabel" class="bcm-badge bcm-badge-team">{{ t('role.moderator') }}</span>
122
149
  <span v-if="handle" class="bcm-handle">{{ handle }}</span>
123
150
  <time v-if="m.createdAt" class="bcm-time" :datetime="m.createdAt">{{ formatDate(lang, m.createdAt) }}</time>
124
151
  <span v-if="m.isEdited && !placeholder" class="bcm-muted">{{ t('discussion.edited') }}</span>
@@ -134,7 +161,7 @@ function toggle(set: Set<number>): void {
134
161
  <div v-if="untilConsent" class="bcm-own-state bcm-own-consent">
135
162
  <span class="bcm-badge">{{ t('state.hiddenUntilConsent') }}</span>
136
163
  <p class="bcm-hint">{{ t('discussion.hiddenUntilConsentNote') }}</p>
137
- <button v-if="canGiveConsent" type="button" class="bcm-link" @click="ctl.beginOnboarding()">{{ t('discussion.giveConsent') }}</button>
164
+ <button v-if="canGiveConsent" type="button" class="bcm-link" @click="ctl.beginOnboarding(refusedConsent ? { offerArt9: true } : {})">{{ t('discussion.giveConsent') }}</button>
138
165
  </div>
139
166
  <div v-else-if="own && (m.state === 'held' || m.state === 'removed')" class="bcm-own-state">
140
167
  <span class="bcm-badge">{{ m.state === 'held' ? t('state.held') : t('state.removed') }}</span>
@@ -147,12 +174,13 @@ function toggle(set: Set<number>): void {
147
174
  :draft="ctl.state.inline"
148
175
  :wait="ctl.waitLeft(ctl.state.inline)"
149
176
  :rules-href="routes.rulesPath"
177
+ :mentions-max="ctl.mentionCap()"
150
178
  @submit="ctl.submitInline()"
151
179
  @cancel="ctl.closeInline()"
152
180
  @confirm="(code: string, checked: boolean) => ctl.state.inline && ctl.setConfirmed(ctl.state.inline, code, checked)"
153
181
  @confirmed="ctl.state.inline && ctl.sendConfirmed(ctl.state.inline)"
154
182
  />
155
- <p v-else class="bcm-text"><LinkedText :text="text" /></p>
183
+ <p v-else class="bcm-text"><LinkedText :text="text" :profile-path="mentionProfiles" /></p>
156
184
  <p v-if="m.isTranslated && !editing" class="bcm-hint">
157
185
  {{ t('discussion.translated') }}
158
186
  <button type="button" class="bcm-link" @click="toggle(originals)">{{ showOriginal ? t('discussion.showTranslation') : t('discussion.showOriginal') }}</button>
@@ -192,6 +220,7 @@ function toggle(set: Set<number>): void {
192
220
  :draft="ctl.state.inline"
193
221
  :wait="ctl.waitLeft(ctl.state.inline)"
194
222
  :rules-href="routes.rulesPath"
223
+ :mentions-max="ctl.mentionCap()"
195
224
  :replying-to="ctl.state.inline.replyingTo"
196
225
  @submit="ctl.submitInline()"
197
226
  @cancel="ctl.closeInline()"
@@ -8,7 +8,8 @@ import { nextTick, onBeforeUnmount, ref } from 'vue'
8
8
  import { uid, useText } from './context'
9
9
  import type { MenuItem } from './types'
10
10
 
11
- defineProps<{ items: MenuItem[] }>()
11
+ /** M3: the toggle's own text (a topic's moderator menu); absent: „Další akce". */
12
+ defineProps<{ items: MenuItem[]; label?: string }>()
12
13
  const emit = defineEmits<{ select: [id: string] }>()
13
14
  const { t } = useText()
14
15
 
@@ -55,7 +56,7 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onOutside, tru
55
56
  :aria-controls="listId"
56
57
  @click="setOpen(!open)"
57
58
  >
58
- {{ t('discussion.menu') }}
59
+ {{ label ?? t('discussion.menu') }}
59
60
  </button>
60
61
  <div v-if="open" :id="listId" class="bcm-menu-list">
61
62
  <template v-for="item in items" :key="item.id">
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * A category moderator's dialog in a topic (m3-contracts.md §3.4, §4.5; D-M3-6): remove a message — the rule of the
4
+ * tenant's rules text it breaks and the facts, as the statement of reasons will state them; the statement comes back
5
+ * and is shown —, pass a message to the team (an illegal line's matter: the moderator sees nothing of it afterwards), or
6
+ * move the topic into another category they moderate. A native modal dialog; the focus returns where it was.
7
+ */
8
+ import { computed, ref } from 'vue'
9
+ import { ESCALATION_CATEGORIES, type CaseCategory, type CommunityMessageKey } from '@blogmcp/sdk/community'
10
+ import type { TopicController } from '../core/topic'
11
+ import { failureText } from '../core/outcomes'
12
+ import { codePointLength, normalizeBody } from '../core/text'
13
+ import { uid, useModal, useText } from './context'
14
+
15
+ const props = defineProps<{ ctl: TopicController; fallbackFocus?: () => HTMLElement | null }>()
16
+ const emit = defineEmits<{ close: [] }>()
17
+ const { t, lang } = useText()
18
+
19
+ const dialog = ref<HTMLDialogElement | null>(null)
20
+ const id = uid('bcm-moderator')
21
+ useModal(dialog, () => emit('close'), props.fallbackFocus)
22
+
23
+ const d = computed(() => props.ctl.extras.dialog)
24
+ const titleKey = computed<CommunityMessageKey>(() => (d.value?.kind === 'remove' ? 'moderation.remove' : d.value?.kind === 'escalate' ? 'moderation.escalate' : 'moderation.move'))
25
+ const categoryKey = (c: CaseCategory): CommunityMessageKey => `category.${c}`
26
+ const failure = computed(() => {
27
+ const f = d.value?.failure
28
+ if (!f) return ''
29
+ const r = failureText(f, lang.value)
30
+ return t(r.key, r.params)
31
+ })
32
+ const canSend = computed(() => {
33
+ const x = d.value
34
+ if (!x || x.sending || x.done) return false
35
+ if (x.kind === 'move') return x.categoryId !== ''
36
+ if (x.kind === 'escalate') return x.category !== ''
37
+ const facts = normalizeBody(x.facts)
38
+ return x.ruleId !== '' && facts !== '' && codePointLength(facts) <= 4000
39
+ })
40
+ </script>
41
+
42
+ <template>
43
+ <dialog ref="dialog" class="bcm-dialog" :aria-labelledby="`${id}-title`">
44
+ <form v-if="d" class="bcm-dialog-body bcm-form" novalidate @submit.prevent="canSend && ctl.submitDialog()">
45
+ <h3 :id="`${id}-title`">{{ t(titleKey) }}</h3>
46
+
47
+ <template v-if="d.kind === 'remove'">
48
+ <template v-if="!d.done">
49
+ <p>{{ t('moderation.removeLede') }}</p>
50
+ <div class="bcm-field">
51
+ <label :for="`${id}-rule`">{{ t('statement.contractualGround') }}</label>
52
+ <select :id="`${id}-rule`" v-model="d.ruleId" class="bcm-input bcm-select" required>
53
+ <option value="" disabled>{{ t('moderation.ruleRequired') }}</option>
54
+ <option v-for="r in ctl.extras.rules ?? []" :key="r.id" :value="r.id">{{ r.title }}</option>
55
+ </select>
56
+ </div>
57
+ <div class="bcm-field">
58
+ <label :for="`${id}-facts`">{{ t('statement.facts') }}</label>
59
+ <textarea :id="`${id}-facts`" v-model="d.facts" class="bcm-input bcm-textarea" rows="4" :aria-describedby="`${id}-facts-hint`" />
60
+ <p :id="`${id}-facts-hint`" class="bcm-hint">{{ t('moderation.factsHint') }}</p>
61
+ </div>
62
+ </template>
63
+ <template v-else>
64
+ <p role="status">{{ t('moderation.removed') }}</p>
65
+ <h4>{{ t('statement.title') }}</h4>
66
+ <pre class="bcm-facts">{{ d.statement }}</pre>
67
+ </template>
68
+ </template>
69
+
70
+ <template v-else-if="d.kind === 'escalate'">
71
+ <template v-if="!d.done">
72
+ <p>{{ t('moderation.escalateLede') }}</p>
73
+ <div class="bcm-field">
74
+ <label :for="`${id}-category`">{{ t('moderation.escalateCategory') }}</label>
75
+ <select :id="`${id}-category`" v-model="d.category" class="bcm-input bcm-select" required>
76
+ <option value="" disabled>{{ t('moderation.escalateCategory') }}</option>
77
+ <option v-for="c in ESCALATION_CATEGORIES" :key="c" :value="c">{{ t(categoryKey(c)) }}</option>
78
+ </select>
79
+ </div>
80
+ </template>
81
+ <p v-else role="status">{{ t('moderation.escalated') }}</p>
82
+ </template>
83
+
84
+ <template v-else>
85
+ <div class="bcm-field">
86
+ <label :for="`${id}-target`">{{ t('moderation.moveTo') }}</label>
87
+ <select :id="`${id}-target`" v-model="d.categoryId" class="bcm-input bcm-select" required>
88
+ <option value="" disabled>{{ t('moderation.moveTo') }}</option>
89
+ <option v-for="c in ctl.extras.moveTargets ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
90
+ </select>
91
+ </div>
92
+ </template>
93
+
94
+ <p v-if="failure" class="bcm-error" role="alert">{{ failure }}</p>
95
+ <div class="bcm-row">
96
+ <span class="bcm-spacer" />
97
+ <button type="button" class="bcm-btn" :disabled="d.sending" @click="emit('close')">{{ d.done ? t('common.close') : t('common.cancel') }}</button>
98
+ <button v-if="!d.done" type="submit" class="bcm-btn bcm-primary" :class="{ 'bcm-danger': d.kind === 'remove' }" :disabled="!canSend">{{ t(titleKey) }}</button>
99
+ </div>
100
+ </form>
101
+ </dialog>
102
+ </template>
@@ -10,6 +10,7 @@
10
10
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, useHost, watch } from 'vue'
11
11
  import {
12
12
  COMMUNITY_HANDLE_CHANGE_DAYS,
13
+ adverseOutcomeMessageKey,
13
14
  signInLink,
14
15
  statementPath,
15
16
  type CommunityMessageKey,
@@ -29,6 +30,7 @@ import { receiptForElement } from '../core/receipt'
29
30
  import AuthorAvatar from './AuthorAvatar.vue'
30
31
  import ConfirmDialog from './ConfirmDialog.vue'
31
32
  import NoticeStatusView from './NoticeStatusView.vue'
33
+ import OnboardingSteps from './OnboardingSteps.vue'
32
34
  import StatementView from './StatementView.vue'
33
35
  import { provideText, uid } from './context'
34
36
 
@@ -120,6 +122,7 @@ const appealStatusKey = (decisionId: string): CommunityMessageKey | null => {
120
122
  }
121
123
 
122
124
  const consent = computed(() => state?.me?.acceptances.art9Consent ?? null)
125
+ const consentRefused = computed(() => state?.me?.acceptances.art9Refused === true)
123
126
  const member = computed(() => state?.me?.member ?? null)
124
127
  const profile = computed(() => state?.me?.profile ?? null)
125
128
 
@@ -281,6 +284,8 @@ watch(
281
284
  <li v-for="r in state.reports" :key="r.caseId">
282
285
  <p v-if="categoryKey(r.category)"><strong>{{ t(categoryKey(r.category)!) }}</strong></p>
283
286
  <p v-if="caseStatusKey(r.status)">{{ t(caseStatusKey(r.status)!) }}</p>
287
+ <!-- M3 (§3.5, §4.7): an adverse-event case's outcome, where M1 shows a decision. -->
288
+ <p v-if="adverseOutcomeMessageKey(r.outcome)">{{ t(adverseOutcomeMessageKey(r.outcome)!) }}</p>
284
289
  <time class="bcm-time" :datetime="r.reportedAt">{{ formatDate(lang, r.reportedAt) }}</time>
285
290
  <p v-if="r.decidedAt" class="bcm-muted">{{ t('statement.decidedAt', { date: formatDate(lang, r.decidedAt) }) }}</p>
286
291
  </li>
@@ -309,6 +314,13 @@ watch(
309
314
  <button type="button" class="bcm-btn" :disabled="!!state.busy.consent" @click="confirming = 'consent'">{{ t('panel.art9Withdraw') }}</button>
310
315
  </div>
311
316
  </template>
317
+ <template v-else-if="consentRefused">
318
+ <p>{{ t('panel.art9Refused') }}</p>
319
+ <OnboardingSteps v-if="ctl.consent.value" :model="ctl.consent.value" :rules-href="routes.rulesPath" @cancel="ctl.closeConsent()" />
320
+ <div v-else class="bcm-row">
321
+ <button type="button" class="bcm-btn bcm-primary" @click="ctl.giveConsent()">{{ t('discussion.giveConsent') }}</button>
322
+ </div>
323
+ </template>
312
324
  <p v-else>{{ t('panel.art9NotGiven') }}</p>
313
325
  </section>
314
326
 
@@ -3,9 +3,13 @@
3
3
  * A notifier's view of their notice (DSA art. 16(5), 17; m1-contracts.md §7.7 `notices/status`): its status,
4
4
  * the statement once decided — never the case's package —, and the appeal while its window is open. The
5
5
  * receipt that opened it came in the address's fragment and is gone from the address bar already.
6
+ *
7
+ * M3 (m3-contracts.md §3.5, §4.7): an adverse-event notice the team closed with its outcome reads the outcome in plain
8
+ * words and the redress line (a court or an out-of-court settlement), as the `notice_closed` e-mail does — a case
9
+ * closed by its outcome alone has no decision, so no statement and no appeal; a decided case keeps both.
6
10
  */
7
11
  import { computed } from 'vue'
8
- import { COMMUNITY_APPEAL_MAX, type NoticeStatus } from '@blogmcp/sdk/community'
12
+ import { COMMUNITY_APPEAL_MAX, adverseOutcomeMessageKey, type NoticeStatus } from '@blogmcp/sdk/community'
9
13
  import type { AppealDraft } from '../core/panel'
10
14
  import { formatDate, isPast } from '../core/format'
11
15
  import { knownKey } from '../core/i18n'
@@ -21,6 +25,10 @@ const id = uid('bcm-notice-status')
21
25
  const n = computed(() => props.notice)
22
26
  const category = computed(() => knownKey(`category.${n.value.category}`))
23
27
  const status = computed(() => knownKey(`caseStatus.${n.value.status}`))
28
+ /** M3: the adverse-event outcome's words (null: none, or one this SDK has no words for). */
29
+ const outcome = computed(() => adverseOutcomeMessageKey(n.value.outcome))
30
+ /** Closed by its outcome alone: no decision — nothing to appeal; the redress line says what is left. */
31
+ const outcomeOnly = computed(() => outcome.value !== null && n.value.statement === null && !n.value.appeal.canAppeal && !n.value.appeal.open)
24
32
  const length = computed(() => codePointLength(normalizeBody(props.appeal.body)))
25
33
  const canSend = computed(() => !props.appeal.sending && length.value > 0 && length.value <= COMMUNITY_APPEAL_MAX)
26
34
  const closed = computed(() => (n.value.appeal.closesAt ? isPast(n.value.appeal.closesAt) : false))
@@ -47,12 +55,17 @@ const failure = computed(() => {
47
55
  </dl>
48
56
  <p class="bcm-muted">{{ t('notice.submittedAt', { date: formatDate(lang, n.submittedAt) }) }}</p>
49
57
  <p v-if="n.decidedAt" class="bcm-muted">{{ t('notice.decidedAt', { date: formatDate(lang, n.decidedAt) }) }}</p>
58
+ <template v-if="outcome">
59
+ <h4>{{ t('notice.outcome') }}</h4>
60
+ <p>{{ t(outcome) }}</p>
61
+ <p v-if="outcomeOnly">{{ t('notice.redress') }}</p>
62
+ </template>
50
63
  <template v-if="n.statement">
51
64
  <h4>{{ t('notice.statement') }}</h4>
52
65
  <pre class="bcm-facts">{{ n.statement }}</pre>
53
66
  </template>
54
67
 
55
- <section v-if="n.decidedAt" class="bcm-card" :aria-labelledby="`${id}-appeal`">
68
+ <section v-if="n.decidedAt && !outcomeOnly" class="bcm-card" :aria-labelledby="`${id}-appeal`">
56
69
  <h4 :id="`${id}-appeal`">{{ t('notice.appealTitle') }}</h4>
57
70
  <template v-if="n.appeal.open">
58
71
  <p>{{ t('notice.appealOpen') }}</p>