@things-factory/announcement 0.0.0-stage → 10.1.223

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 (122) hide show
  1. package/client/announcement-watch.ts +109 -0
  2. package/client/bootstrap.ts +9 -0
  3. package/client/components/announcement-popup.ts +92 -0
  4. package/client/components/announcement-receipts.ts +196 -0
  5. package/client/components/announcement-sheet.ts +214 -0
  6. package/client/components/audience-picker.ts +221 -0
  7. package/client/index.ts +1 -0
  8. package/client/pages/announcement-editor.ts +505 -0
  9. package/client/pages/announcement-list.ts +200 -0
  10. package/client/pages/announcement-manage-list.ts +113 -0
  11. package/client/pages/announcement-view.ts +172 -0
  12. package/client/route.ts +17 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/announcement-api.ts +142 -0
  15. package/client/utils/announcement-badge.ts +46 -0
  16. package/client/utils/announcement-words.ts +26 -0
  17. package/dist-client/announcement-watch.d.ts +6 -0
  18. package/dist-client/announcement-watch.js +105 -0
  19. package/dist-client/announcement-watch.js.map +1 -0
  20. package/dist-client/bootstrap.d.ts +1 -0
  21. package/dist-client/bootstrap.js +8 -0
  22. package/dist-client/bootstrap.js.map +1 -0
  23. package/dist-client/components/announcement-popup.d.ts +20 -0
  24. package/dist-client/components/announcement-popup.js +98 -0
  25. package/dist-client/components/announcement-popup.js.map +1 -0
  26. package/dist-client/components/announcement-receipts.d.ts +30 -0
  27. package/dist-client/components/announcement-receipts.js +220 -0
  28. package/dist-client/components/announcement-receipts.js.map +1 -0
  29. package/dist-client/components/announcement-sheet.d.ts +17 -0
  30. package/dist-client/components/announcement-sheet.js +224 -0
  31. package/dist-client/components/announcement-sheet.js.map +1 -0
  32. package/dist-client/components/audience-picker.d.ts +32 -0
  33. package/dist-client/components/audience-picker.js +223 -0
  34. package/dist-client/components/audience-picker.js.map +1 -0
  35. package/dist-client/index.d.ts +1 -0
  36. package/dist-client/index.js +2 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/pages/announcement-editor.d.ts +59 -0
  39. package/dist-client/pages/announcement-editor.js +523 -0
  40. package/dist-client/pages/announcement-editor.js.map +1 -0
  41. package/dist-client/pages/announcement-list.d.ts +54 -0
  42. package/dist-client/pages/announcement-list.js +209 -0
  43. package/dist-client/pages/announcement-list.js.map +1 -0
  44. package/dist-client/pages/announcement-manage-list.d.ts +46 -0
  45. package/dist-client/pages/announcement-manage-list.js +115 -0
  46. package/dist-client/pages/announcement-manage-list.js.map +1 -0
  47. package/dist-client/pages/announcement-view.d.ts +30 -0
  48. package/dist-client/pages/announcement-view.js +182 -0
  49. package/dist-client/pages/announcement-view.js.map +1 -0
  50. package/dist-client/route.d.ts +1 -0
  51. package/dist-client/route.js +18 -0
  52. package/dist-client/route.js.map +1 -0
  53. package/dist-client/tsconfig.tsbuildinfo +1 -0
  54. package/dist-client/utils/announcement-api.d.ts +149 -0
  55. package/dist-client/utils/announcement-api.js +52 -0
  56. package/dist-client/utils/announcement-api.js.map +1 -0
  57. package/dist-client/utils/announcement-badge.d.ts +4 -0
  58. package/dist-client/utils/announcement-badge.js +47 -0
  59. package/dist-client/utils/announcement-badge.js.map +1 -0
  60. package/dist-client/utils/announcement-words.d.ts +11 -0
  61. package/dist-client/utils/announcement-words.js +23 -0
  62. package/dist-client/utils/announcement-words.js.map +1 -0
  63. package/dist-server/announcement-read.d.ts +6 -0
  64. package/dist-server/announcement-read.js +45 -0
  65. package/dist-server/announcement-read.js.map +1 -0
  66. package/dist-server/attachment-reader.d.ts +1 -0
  67. package/dist-server/attachment-reader.js +13 -0
  68. package/dist-server/attachment-reader.js.map +1 -0
  69. package/dist-server/index.d.ts +2 -0
  70. package/dist-server/index.js +20 -0
  71. package/dist-server/index.js.map +1 -0
  72. package/dist-server/role-tasks.d.ts +2 -0
  73. package/dist-server/role-tasks.js +13 -0
  74. package/dist-server/role-tasks.js.map +1 -0
  75. package/dist-server/service/announcement/announcement-resolver.d.ts +38 -0
  76. package/dist-server/service/announcement/announcement-resolver.js +664 -0
  77. package/dist-server/service/announcement/announcement-resolver.js.map +1 -0
  78. package/dist-server/service/announcement/announcement-rules.d.ts +150 -0
  79. package/dist-server/service/announcement/announcement-rules.js +227 -0
  80. package/dist-server/service/announcement/announcement-rules.js.map +1 -0
  81. package/dist-server/service/announcement/announcement-service.d.ts +54 -0
  82. package/dist-server/service/announcement/announcement-service.js +248 -0
  83. package/dist-server/service/announcement/announcement-service.js.map +1 -0
  84. package/dist-server/service/announcement/announcement-types.d.ts +54 -0
  85. package/dist-server/service/announcement/announcement-types.js +203 -0
  86. package/dist-server/service/announcement/announcement-types.js.map +1 -0
  87. package/dist-server/service/announcement/announcement.d.ts +124 -0
  88. package/dist-server/service/announcement/announcement.js +462 -0
  89. package/dist-server/service/announcement/announcement.js.map +1 -0
  90. package/dist-server/service/announcement/index.d.ts +4 -0
  91. package/dist-server/service/announcement/index.js +8 -0
  92. package/dist-server/service/announcement/index.js.map +1 -0
  93. package/dist-server/service/index.d.ts +7 -0
  94. package/dist-server/service/index.js +23 -0
  95. package/dist-server/service/index.js.map +1 -0
  96. package/dist-server/tsconfig.tsbuildinfo +1 -0
  97. package/package.json +51 -4
  98. package/server/announcement-read.ts +44 -0
  99. package/server/attachment-reader.ts +11 -0
  100. package/server/index.ts +16 -0
  101. package/server/role-tasks.ts +12 -0
  102. package/server/service/announcement/announcement-resolver.ts +506 -0
  103. package/server/service/announcement/announcement-rules.ts +239 -0
  104. package/server/service/announcement/announcement-service.ts +240 -0
  105. package/server/service/announcement/announcement-types.ts +138 -0
  106. package/server/service/announcement/announcement.ts +367 -0
  107. package/server/service/announcement/index.ts +5 -0
  108. package/server/service/index.ts +19 -0
  109. package/server/tsconfig.json +11 -0
  110. package/test/announcement-persistence.test.ts +271 -0
  111. package/test/announcement-read.test.ts +49 -0
  112. package/test/announcement-rules.test.ts +217 -0
  113. package/test/open-doors.test.ts +45 -0
  114. package/test/translations.test.ts +54 -0
  115. package/things-factory.config.js +13 -0
  116. package/translations/en.json +169 -0
  117. package/translations/es.json +169 -0
  118. package/translations/ja.json +169 -0
  119. package/translations/ko.json +169 -0
  120. package/translations/ms.json +169 -0
  121. package/translations/zh.json +169 -0
  122. package/README.md +0 -3
@@ -0,0 +1,109 @@
1
+ /*
2
+ * Keeps the person up with announcements while the application is open (ADR-0163 decision 5): the popups due at sign-in,
3
+ * a new one popping up the moment it is published to them, and the menu badge.
4
+ *
5
+ * The notice of an announcement reaches the screen through notification's subscription; the announcements themselves
6
+ * are asked of the server - a screen that was closed when one went out still finds it here.
7
+ */
8
+ import './components/announcement-popup.js'
9
+
10
+ import gql from 'graphql-tag'
11
+ import { html } from 'lit'
12
+
13
+ import { subscribe } from '@operato/graphql'
14
+ import { openPopup } from '@operato/layout'
15
+
16
+ import { announcementApi, type AnnouncementItem } from './utils/announcement-api.js'
17
+ import { refreshAnnouncementBadge } from './utils/announcement-badge.js'
18
+ import { t } from './utils/announcement-words.js'
19
+
20
+ /* Closed in this browser session - asked again at the next sign-in (one still to be acknowledged comes back then). */
21
+ const CLOSED_KEY = 'announcement.popups-closed'
22
+
23
+ function closedInSession(): Set<string> {
24
+ try {
25
+ return new Set(JSON.parse(sessionStorage.getItem(CLOSED_KEY) || '[]'))
26
+ } catch {
27
+ return new Set()
28
+ }
29
+ }
30
+
31
+ function rememberClosed(ids: string[]) {
32
+ try {
33
+ sessionStorage.setItem(CLOSED_KEY, JSON.stringify([...new Set([...closedInSession(), ...ids])]))
34
+ } catch {
35
+ /* a browser that keeps nothing shows them again - no harm */
36
+ }
37
+ }
38
+
39
+ let popup: { close: () => void; closed: boolean } | undefined
40
+
41
+ /* A reading changed on a screen or in the popup - the badge counts again. */
42
+ window.addEventListener('announcements-changed', () => refreshAnnouncementBadge())
43
+ let subscription: { unsubscribe(): void } | undefined
44
+ let watching = false
45
+
46
+ /** Opens the popups due now that this session has not closed - one window at a time. */
47
+ export async function showDuePopups(): Promise<void> {
48
+ if (popup && !popup.closed) return
49
+ let due: AnnouncementItem[]
50
+ try {
51
+ due = await announcementApi.popups()
52
+ } catch {
53
+ return
54
+ }
55
+ const closed = closedInSession()
56
+ const items = due.filter(one => !closed.has(one.id))
57
+ if (!items.length) return
58
+ const handle: any = openPopup(html`<announcement-popup .items=${items} @done=${() => handle.close()}></announcement-popup>`, {
59
+ backdrop: true,
60
+ size: 'medium',
61
+ title: t('announcement.title.popup')
62
+ })
63
+ popup = handle
64
+ handle.result?.then?.(() => {
65
+ rememberClosed(items.map(one => one.id))
66
+ refreshAnnouncementBadge()
67
+ })
68
+ }
69
+
70
+ /** Starts once the person is known. */
71
+ export async function startAnnouncementWatch(): Promise<void> {
72
+ if (watching) return
73
+ watching = true
74
+ refreshAnnouncementBadge()
75
+ showDuePopups()
76
+ try {
77
+ subscription = await subscribe(
78
+ {
79
+ query: gql`
80
+ subscription {
81
+ notification(subjects: ["info"]) {
82
+ type
83
+ }
84
+ }
85
+ `
86
+ },
87
+ {
88
+ next({ data }: any) {
89
+ if (data?.notification?.type !== 'announcement') return
90
+ refreshAnnouncementBadge()
91
+ showDuePopups()
92
+ }
93
+ }
94
+ )
95
+ } catch {
96
+ /* without the live line the badge and popups still come at the next sign-in */
97
+ }
98
+ }
99
+
100
+ export function stopAnnouncementWatch(): void {
101
+ subscription?.unsubscribe()
102
+ subscription = undefined
103
+ popup?.close()
104
+ popup = undefined
105
+ watching = false
106
+ try {
107
+ sessionStorage.removeItem(CLOSED_KEY)
108
+ } catch {}
109
+ }
@@ -0,0 +1,9 @@
1
+ import { auth } from '@things-factory/auth-base/dist-client/auth.js'
2
+
3
+ import { startAnnouncementWatch, stopAnnouncementWatch } from './announcement-watch.js'
4
+
5
+ /* Announcements follow the person - the popups due and the badge come when they are known (ADR-0163 decision 5). */
6
+ export default function bootstrap() {
7
+ auth.on('profile', () => startAnnouncementWatch())
8
+ auth.on('presignout', () => stopAnnouncementWatch())
9
+ }
@@ -0,0 +1,92 @@
1
+ /*
2
+ * The announcements that pop up (the user, 2026-10-26: 「공지 사항이 있을 때 자동 팝업」, ADR-0163 decision 5) - one
3
+ * window, one announcement at a time. Seeing one reads it; one that asks to be acknowledged is acknowledged here or
4
+ * shows again at the next sign-in.
5
+ */
6
+ import '@material/web/button/filled-button.js'
7
+ import '@material/web/button/text-button.js'
8
+ import '@operato/popup/ox-popup-actions.js'
9
+ import './announcement-sheet.js'
10
+
11
+ import { css, html, LitElement, nothing } from 'lit'
12
+ import { customElement, property, state } from 'lit/decorators.js'
13
+
14
+ import { i18next, localize } from '@operato/i18n'
15
+ import { navigate } from '@operato/shell'
16
+ import { CommonPopupStyles, PopupBodyStyles } from '@operato/styles'
17
+
18
+ import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
19
+ import { t } from '../utils/announcement-words.js'
20
+
21
+ @customElement('announcement-popup')
22
+ export class AnnouncementPopup extends localize(i18next)(LitElement) {
23
+ static styles = [
24
+ CommonPopupStyles,
25
+ PopupBodyStyles,
26
+ css`
27
+ :host {
28
+ height: 100%;
29
+ }
30
+ h3 {
31
+ margin: 0 0 var(--spacing-small, 4px);
32
+ font-size: var(--ui-t-title, 18px);
33
+ }
34
+ .count {
35
+ margin-inline-end: auto;
36
+ align-self: center;
37
+ font-size: var(--ui-t-small, 12px);
38
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
39
+ }
40
+ `
41
+ ]
42
+
43
+ @property({ attribute: false }) items: AnnouncementItem[] = []
44
+ @state() private index = 0
45
+
46
+ render() {
47
+ const one = this.items[this.index]
48
+ if (!one) return nothing
49
+ const last = this.index >= this.items.length - 1
50
+ return html`<div class="body">
51
+ <h3>${one.title}</h3>
52
+ <announcement-sheet .announcement=${one} @announcement-follow=${() => this.close()} @announcement-acknowledged=${(e: CustomEvent) => this.replace(e.detail)}></announcement-sheet>
53
+ </div>
54
+ <ox-popup-actions>
55
+ ${this.items.length > 1 ? html`<span class="count">${this.index + 1} / ${this.items.length}</span>` : nothing}
56
+ <md-text-button @click=${() => this.open(one)}>${t('announcement.button.open')}</md-text-button>
57
+ ${this.index > 0 ? html`<md-text-button @click=${() => this.show(this.index - 1)}>${t('announcement.button.previous')}</md-text-button>` : nothing}
58
+ ${last
59
+ ? html`<md-filled-button slot="confirm" @click=${() => this.close()}>${t('announcement.button.close')}</md-filled-button>`
60
+ : html`<md-filled-button slot="confirm" @click=${() => this.show(this.index + 1)}>${t('announcement.button.next')}</md-filled-button>`}
61
+ </ox-popup-actions>`
62
+ }
63
+
64
+ firstUpdated() {
65
+ this.show(0)
66
+ }
67
+
68
+ /** Shows one, and reads it - seeing it in the popup is reading it. */
69
+ private show(index: number) {
70
+ this.index = index
71
+ const one = this.items[index]
72
+ if (one && !one.mine?.readAt) {
73
+ announcementApi
74
+ .read(one.id)
75
+ .then(done => this.replace({ ...one, ...done }))
76
+ .catch(() => {})
77
+ }
78
+ }
79
+
80
+ private replace(one: AnnouncementItem) {
81
+ this.items = this.items.map(item => (item.id === one.id ? { ...item, ...one } : item))
82
+ }
83
+
84
+ private open(one: AnnouncementItem) {
85
+ this.close()
86
+ navigate(`announcement-view?id=${one.id}`)
87
+ }
88
+
89
+ private close() {
90
+ this.dispatchEvent(new CustomEvent('done', { bubbles: true, composed: true }))
91
+ }
92
+ }
@@ -0,0 +1,196 @@
1
+ /*
2
+ * The small forms a writer opens from an announcement (ADR-0163 decision 4): who read it and who acknowledged it, with
3
+ * a reminder to those who have not; and the reason it is withdrawn.
4
+ */
5
+ import '@material/web/button/filled-button.js'
6
+ import '@material/web/button/text-button.js'
7
+ import '@operato/popup/ox-popup-actions.js'
8
+
9
+ import { css, html, LitElement, nothing } from 'lit'
10
+ import { customElement, property, state } from 'lit/decorators.js'
11
+
12
+ import { i18next, localize } from '@operato/i18n'
13
+ import { notify } from '@operato/layout'
14
+ import { CommonPopupStyles, PopupBodyStyles } from '@operato/styles'
15
+
16
+ import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
17
+ import { reasonOf, t, when } from '../utils/announcement-words.js'
18
+
19
+ type Row = Awaited<ReturnType<typeof announcementApi.receipts>>[number]
20
+
21
+ const FormStyles = css`
22
+ :host {
23
+ height: 100%;
24
+ line-height: 1.5;
25
+ }
26
+ .tabs {
27
+ display: flex;
28
+ gap: var(--spacing-small, 4px);
29
+ margin-bottom: var(--spacing-medium, 8px);
30
+ }
31
+ .tabs button {
32
+ border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
33
+ background: none;
34
+ border-radius: 999px;
35
+ padding: 2px var(--spacing-medium, 8px);
36
+ font: inherit;
37
+ font-size: var(--ui-t-small, 12px);
38
+ cursor: pointer;
39
+ color: inherit;
40
+ }
41
+ .tabs button[aria-pressed='true'] {
42
+ background: var(--md-sys-color-secondary-container);
43
+ border-color: transparent;
44
+ }
45
+ table {
46
+ width: 100%;
47
+ border-collapse: collapse;
48
+ font-size: var(--ui-t-body, 14px);
49
+ font-variant-numeric: tabular-nums;
50
+ }
51
+ th,
52
+ td {
53
+ text-align: left;
54
+ padding: var(--spacing-small, 4px) var(--spacing-medium, 8px);
55
+ border-bottom: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
56
+ }
57
+ th {
58
+ font-weight: 600;
59
+ font-size: var(--ui-t-small, 12px);
60
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
61
+ }
62
+ .muted {
63
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
64
+ }
65
+ textarea {
66
+ width: 100%;
67
+ min-height: 96px;
68
+ box-sizing: border-box;
69
+ font: inherit;
70
+ }
71
+ `
72
+
73
+ @customElement('announcement-receipts')
74
+ export class AnnouncementReceipts extends localize(i18next)(LitElement) {
75
+ static styles = [CommonPopupStyles, PopupBodyStyles, FormStyles]
76
+ @property({ attribute: false }) announcement!: AnnouncementItem
77
+ @state() private rows: Row[] = []
78
+ @state() private which: 'all' | 'unread' | 'unacknowledged' | 'later' = 'all'
79
+ @state() private busy = false
80
+
81
+ connectedCallback() {
82
+ super.connectedCallback()
83
+ this.load()
84
+ }
85
+
86
+ private async load() {
87
+ try {
88
+ this.rows = await announcementApi.receipts(this.announcement.id)
89
+ } catch (error) {
90
+ notify({ level: 'error', message: reasonOf(error) })
91
+ }
92
+ }
93
+
94
+ render() {
95
+ const one = this.announcement
96
+ const listed = this.rows.filter(row => row.listed)
97
+ const counts = {
98
+ all: listed.length,
99
+ unread: listed.filter(row => !row.readAt).length,
100
+ unacknowledged: listed.filter(row => !row.acknowledgedAt).length,
101
+ later: this.rows.filter(row => !row.listed).length
102
+ }
103
+ const tabs = (['all', 'unread', ...(one.requiresAcknowledgement ? ['unacknowledged'] : []), ...(counts.later ? ['later'] : [])] as (keyof typeof counts)[]).map(
104
+ key => html`<button aria-pressed=${String(this.which === key)} @click=${() => (this.which = key)}>${t(`announcement.receipts.${key}`)} ${counts[key]}</button>`
105
+ )
106
+ const shown =
107
+ this.which === 'later'
108
+ ? this.rows.filter(row => !row.listed)
109
+ : listed.filter(row => (this.which === 'unread' ? !row.readAt : this.which === 'unacknowledged' ? !row.acknowledgedAt : true))
110
+ const owed = one.requiresAcknowledgement ? counts.unacknowledged : counts.unread
111
+ return html`<div class="body">
112
+ <div class="tabs">${tabs}</div>
113
+ <table>
114
+ <thead>
115
+ <tr>
116
+ <th>${t('announcement.receipts.name')}</th>
117
+ <th>${t('announcement.receipts.read-at')}</th>
118
+ ${one.requiresAcknowledgement ? html`<th>${t('announcement.receipts.acknowledged-at')}</th>` : nothing}
119
+ <th>${t('announcement.receipts.notified-at')}</th>
120
+ </tr>
121
+ </thead>
122
+ <tbody>
123
+ ${shown.map(
124
+ row => html`<tr>
125
+ <td>${row.name ?? row.email ?? row.userId}</td>
126
+ <td class=${row.readAt ? '' : 'muted'}>${row.readAt ? when(row.readAt) : t('announcement.reader-state.unread')}</td>
127
+ ${one.requiresAcknowledgement ? html`<td class=${row.acknowledgedAt ? '' : 'muted'}>${row.acknowledgedAt ? when(row.acknowledgedAt) : '—'}</td>` : nothing}
128
+ <td class="muted">${when(row.notifiedAt)}</td>
129
+ </tr>`
130
+ )}
131
+ </tbody>
132
+ </table>
133
+ </div>
134
+ <ox-popup-actions>
135
+ <md-text-button slot="cancel" @click=${() => this.close()}>${t('announcement.button.close')}</md-text-button>
136
+ <md-filled-button slot="confirm" ?disabled=${this.busy || !owed || one.status !== 'PUBLISHED'} @click=${() => this.remind()}
137
+ >${t('announcement.button.remind-count', { count: owed })}</md-filled-button
138
+ >
139
+ </ox-popup-actions>`
140
+ }
141
+
142
+ private async remind() {
143
+ this.busy = true
144
+ try {
145
+ const told = await announcementApi.remind(this.announcement.id)
146
+ notify({ message: t('announcement.text.reminded', { count: told }) })
147
+ await this.load()
148
+ } catch (error) {
149
+ notify({ level: 'error', message: reasonOf(error) })
150
+ } finally {
151
+ this.busy = false
152
+ }
153
+ }
154
+
155
+ private close() {
156
+ this.dispatchEvent(new CustomEvent('done', { bubbles: true, composed: true }))
157
+ }
158
+ }
159
+
160
+ /** Why it is withdrawn - the readers' copy goes, the reason stays with its record. */
161
+ @customElement('announcement-withdraw-form')
162
+ export class AnnouncementWithdrawForm extends localize(i18next)(LitElement) {
163
+ static styles = [CommonPopupStyles, PopupBodyStyles, FormStyles]
164
+ @property({ attribute: false }) announcement!: AnnouncementItem
165
+ @state() private reason = ''
166
+ @state() private busy = false
167
+
168
+ render() {
169
+ return html`<div class="body">
170
+ <p>${t('announcement.text.withdraw-lead')}</p>
171
+ <textarea .value=${this.reason} maxlength="500" placeholder=${t('announcement.text.withdraw-reason')} @input=${(e: any) => (this.reason = e.target.value)}></textarea>
172
+ </div>
173
+ <ox-popup-actions>
174
+ <md-text-button slot="cancel" @click=${() => this.close()}>${t('announcement.button.cancel')}</md-text-button>
175
+ <md-filled-button slot="confirm" ?disabled=${this.busy || !this.reason.trim()} @click=${() => this.withdraw()}>${t('announcement.button.withdraw')}</md-filled-button>
176
+ </ox-popup-actions>`
177
+ }
178
+
179
+ private async withdraw() {
180
+ this.busy = true
181
+ try {
182
+ await announcementApi.withdraw(this.announcement.id, this.reason)
183
+ notify({ message: t('announcement.text.withdrawn-done') })
184
+ this.dispatchEvent(new CustomEvent('announcement-withdrawn', { bubbles: true, composed: true }))
185
+ this.close()
186
+ } catch (error) {
187
+ notify({ level: 'error', message: reasonOf(error) })
188
+ } finally {
189
+ this.busy = false
190
+ }
191
+ }
192
+
193
+ private close() {
194
+ this.dispatchEvent(new CustomEvent('done', { bubbles: true, composed: true }))
195
+ }
196
+ }
@@ -0,0 +1,214 @@
1
+ /*
2
+ * One announcement as its reader sees it - what kind it is and from whom, its body, its files, the screens it points to,
3
+ * and the acknowledgement it asks for (ADR-0163 decisions 3, 5). The screen and the popup both draw it.
4
+ */
5
+ import '@material/web/icon/icon.js'
6
+ import '@material/web/button/filled-button.js'
7
+
8
+ import { css, html, LitElement, nothing } from 'lit'
9
+ import { customElement, property, state } from 'lit/decorators.js'
10
+ import { unsafeHTML } from 'lit/directives/unsafe-html.js'
11
+
12
+ import { i18next, localize } from '@operato/i18n'
13
+ import { notify } from '@operato/layout'
14
+ import { navigate } from '@operato/shell'
15
+ import { renderMarkdown } from '@things-factory/ai-assistant/dist-client/index.js'
16
+
17
+ import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
18
+ import { categoryWord, importanceWord, reasonOf, t, when } from '../utils/announcement-words.js'
19
+
20
+ /** A screen of the application opens in place; an https page opens apart. */
21
+ export function followLink(url: string) {
22
+ if (/^https:\/\//i.test(url)) window.open(url, '_blank', 'noopener')
23
+ else navigate(url)
24
+ }
25
+
26
+ @customElement('announcement-sheet')
27
+ export class AnnouncementSheet extends localize(i18next)(LitElement) {
28
+ static styles = css`
29
+ :host {
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: var(--spacing-medium, 8px);
33
+ color: var(--md-sys-color-on-surface);
34
+ }
35
+ .meta {
36
+ display: flex;
37
+ flex-wrap: wrap;
38
+ align-items: center;
39
+ gap: var(--spacing-small, 4px) var(--spacing-medium, 8px);
40
+ font-size: var(--ui-t-small, 12px);
41
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
42
+ }
43
+ .chip {
44
+ display: inline-flex;
45
+ align-items: center;
46
+ gap: 2px;
47
+ padding: 0 var(--spacing-small, 4px);
48
+ border-radius: var(--border-radius, 4px);
49
+ border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
50
+ line-height: 1.6;
51
+ }
52
+ .chip.URGENT {
53
+ color: var(--md-sys-color-error);
54
+ border-color: currentColor;
55
+ }
56
+ .chip.IMPORTANT {
57
+ color: var(--md-sys-color-primary);
58
+ border-color: currentColor;
59
+ }
60
+ .chip md-icon {
61
+ --md-icon-size: 14px;
62
+ }
63
+ .markdown {
64
+ font-size: var(--ui-t-body, 14px);
65
+ line-height: 1.6;
66
+ overflow-wrap: anywhere;
67
+ }
68
+ .markdown :is(h1, h2, h3, h4) {
69
+ margin: var(--spacing-medium, 8px) 0 var(--spacing-small, 4px);
70
+ font-size: var(--ui-t-title, 16px);
71
+ }
72
+ .markdown :is(p, ul, ol, blockquote, pre, table) {
73
+ margin: 0 0 var(--spacing-medium, 8px);
74
+ }
75
+ .markdown :is(ul, ol) {
76
+ padding-inline-start: 1.4em;
77
+ list-style: revert;
78
+ display: block;
79
+ }
80
+ .markdown a {
81
+ color: var(--md-sys-color-primary);
82
+ }
83
+ .markdown table {
84
+ border-collapse: collapse;
85
+ }
86
+ .markdown :is(th, td) {
87
+ border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
88
+ padding: 2px var(--spacing-small, 4px);
89
+ }
90
+ h4 {
91
+ margin: var(--spacing-medium, 8px) 0 var(--spacing-small, 4px);
92
+ font-size: var(--ui-t-small, 12px);
93
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
94
+ font-weight: 600;
95
+ }
96
+ ul.files {
97
+ margin: 0;
98
+ padding: 0;
99
+ list-style: none;
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 2px;
103
+ }
104
+ a,
105
+ .link {
106
+ display: inline-flex;
107
+ align-items: center;
108
+ gap: var(--spacing-small, 4px);
109
+ color: var(--md-sys-color-primary);
110
+ text-decoration: none;
111
+ cursor: pointer;
112
+ background: none;
113
+ border: 0;
114
+ padding: 0;
115
+ font: inherit;
116
+ }
117
+ a:hover,
118
+ .link:hover {
119
+ text-decoration: underline;
120
+ }
121
+ a md-icon,
122
+ .link md-icon {
123
+ --md-icon-size: 16px;
124
+ }
125
+ .size {
126
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
127
+ font-size: var(--ui-t-small, 12px);
128
+ }
129
+ .acknowledge {
130
+ display: flex;
131
+ flex-wrap: wrap;
132
+ align-items: center;
133
+ justify-content: space-between;
134
+ gap: var(--spacing-medium, 8px);
135
+ padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
136
+ border-radius: var(--border-radius, 4px);
137
+ background: var(--md-sys-color-surface-container, rgba(0, 0, 0, 0.04));
138
+ font-size: var(--ui-t-body, 14px);
139
+ }
140
+ .acknowledge.done {
141
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
142
+ }
143
+ `
144
+
145
+ @property({ attribute: false }) announcement!: AnnouncementItem
146
+ @state() private busy = false
147
+
148
+ render() {
149
+ const one = this.announcement
150
+ if (!one) return nothing
151
+ const files = one.attachments ?? []
152
+ const links = one.links ?? []
153
+ return html`<div class="meta">
154
+ ${one.pinned ? html`<span class="chip"><md-icon>push_pin</md-icon>${t('announcement.field.pinned')}</span>` : nothing}
155
+ ${one.importance !== 'NORMAL' ? html`<span class="chip ${one.importance}">${importanceWord(one.importance)}</span>` : nothing}
156
+ <span class="chip">${categoryWord(one.category)}</span>
157
+ <span>${one.publisher?.name ?? one.creator?.name ?? ''}</span>
158
+ <span>${when(one.publishedAt ?? one.startsAt)}</span>
159
+ ${one.endsAt ? html`<span>${t('announcement.text.until', { when: when(one.endsAt) })}</span>` : nothing}
160
+ ${one.version > 1 ? html`<span>${t('announcement.text.version', { version: one.version })}</span>` : nothing}
161
+ </div>
162
+ ${one.body ? html`<div class="markdown">${unsafeHTML(renderMarkdown(one.body))}</div>` : nothing}
163
+ ${files.length
164
+ ? html`<h4>${t('announcement.field.attachments')}</h4>
165
+ <ul class="files">
166
+ ${files.map(
167
+ file =>
168
+ html`<li>
169
+ <a href=${file.fullpath} target="_blank" rel="noopener" download=${file.name}><md-icon>attach_file</md-icon>${file.name}</a>
170
+ ${file.size ? html`<span class="size">${Math.max(1, Math.round(file.size / 1024))} KB</span>` : nothing}
171
+ </li>`
172
+ )}
173
+ </ul>`
174
+ : nothing}
175
+ ${links.length
176
+ ? html`<h4>${t('announcement.field.links')}</h4>
177
+ <ul class="files">
178
+ ${links.map(link => html`<li><button class="link" @click=${() => this.follow(link.url)}><md-icon>open_in_new</md-icon>${link.label}</button></li>`)}
179
+ </ul>`
180
+ : nothing}
181
+ ${this.renderAcknowledge()}`
182
+ }
183
+
184
+ private renderAcknowledge() {
185
+ const one = this.announcement
186
+ if (!one.requiresAcknowledgement || one.status !== 'PUBLISHED') return nothing
187
+ const by = one.acknowledgeBy ? t('announcement.text.acknowledge-by', { when: when(one.acknowledgeBy, false) }) : ''
188
+ if (one.mine?.acknowledgedAt) return html`<div class="acknowledge done">${t('announcement.text.acknowledged-at', { when: when(one.mine.acknowledgedAt) })}</div>`
189
+ /* Only its audience acknowledges; a writer looking at one addressed to others sees what is asked. */
190
+ if (!one.addressed) return html`<div class="acknowledge">${t('announcement.text.acknowledge-asked')} ${by}</div>`
191
+ return html`<div class="acknowledge">
192
+ <span>${t('announcement.text.acknowledge-asked')} ${by}</span>
193
+ <md-filled-button ?disabled=${this.busy} @click=${() => this.acknowledge()}>${t('announcement.button.acknowledge')}</md-filled-button>
194
+ </div>`
195
+ }
196
+
197
+ private follow(url: string) {
198
+ this.dispatchEvent(new CustomEvent('announcement-follow', { bubbles: true, composed: true }))
199
+ followLink(url)
200
+ }
201
+
202
+ private async acknowledge() {
203
+ this.busy = true
204
+ try {
205
+ const done = await announcementApi.acknowledge(this.announcement.id)
206
+ this.announcement = { ...this.announcement, ...done }
207
+ this.dispatchEvent(new CustomEvent('announcement-acknowledged', { bubbles: true, composed: true, detail: this.announcement }))
208
+ } catch (error) {
209
+ notify({ level: 'error', message: reasonOf(error) })
210
+ } finally {
211
+ this.busy = false
212
+ }
213
+ }
214
+ }