• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / Iznik / 22372

18 Jun 2026 04:40PM UTC coverage: 70.948% (+0.1%) from 70.814%
22372

Pull #618

circleci

edwh
feat(bulk-offer): never send bulk/clearance offers to LoveJunk

LoveJunk's draft model is one item per post and can't represent a
multi-item clearance, so bulk offers (messages with messages_bulk_items
rows) must not be pushed at all. Add a NOT EXISTS guard to the
"new offers" selection in both the live Laravel sync (LoveJunkService)
and the legacy V1 cron (iznik-server/scripts/cron/lovejunk.php). Because
bulk offers then never enter the lovejunk table, the edit/outcome paths
skip them too.

Test: test_skips_bulk_offer_message asserts a message with bulk items is
neither sent nor recorded. Full LoveJunkService suite green (13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015f3YY7RTNdsW6WSEa3dRWg
Pull Request #618: feat(bulk-offer): structured multi-item clearance listings + per-item interest

11296 of 15014 branches covered (75.24%)

Branch coverage included in aggregate %.

2080 of 2593 new or added lines in 23 files covered. (80.22%)

2 existing lines in 1 file now uncovered.

120965 of 171406 relevant lines covered (70.57%)

36.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.17
/iznik-nuxt3/modtools/components/ModMessage.vue
1
<template>
2
  <div v-if="message">
1✔
3
    <div ref="top" style="position: relative; top: -66px" />
1✔
4
    <b-card bg-variant="white" no-body>
1✔
5
      <b-card-header class="p-1 p-md-2">
1✔
6
        <div class="d-flex justify-content-between">
1✔
7
          <div class="flex-grow-1" style="min-width: 0">
1✔
8
            <NoticeMessage
1✔
9
              v-if="editing && !message.lat && !message.lng"
1✔
10
              variant="danger"
1✔
11
              class="mb-2 me-2"
1✔
12
            >
1✔
13
              This message needs editing so that we know where it is. Please put
14
              in a postcode (it doesn't have to be exactly right - do your best
15
              based on the subject).
16
              <b-input-group>
1✔
17
                <PostCode
1✔
18
                  class="mt-2"
1✔
19
                  value=""
1✔
20
                  :find="false"
1✔
21
                  @selected="postcodeSelect"
1✔
22
                />
1✔
23
              </b-input-group>
1✔
24
            </NoticeMessage>
1✔
25
            <div v-if="editing && editmessage" class="d-flex flex-wrap">
1✔
26
              <ModGroupSelect
1✔
27
                v-model="editgroup"
1✔
28
                modonly
1✔
29
                class="me-1"
1✔
30
                size="lg"
1✔
31
                :disabled-except-for="memberGroupIds"
1✔
32
                :disabled="fromUser?.tnuserid"
1✔
33
              />
1✔
34
              <div
1✔
35
                v-if="editmessage.item && editmessage.location"
1✔
36
                class="d-flex flex-wrap flex-grow-1"
1✔
37
              >
38
                <b-form-select
1✔
39
                  v-model="editmessage.type"
1✔
40
                  :options="typeOptions"
1✔
41
                  class="type me-1"
1✔
42
                  size="lg"
1✔
43
                />
1✔
44
                <b-form-input
1✔
45
                  v-model="editmessage.item.name"
1✔
46
                  size="lg"
1✔
47
                  class="me-1 flex-grow-1 item-name-input"
1✔
48
                />
1✔
49
              </div>
1✔
50
              <div v-if="editmessage.item && editmessage.location">
1✔
51
                <b-input-group>
1✔
52
                  <PostCode
1✔
53
                    :value="editmessage.location.name"
1✔
54
                    :find="false"
1✔
55
                    @selected="postcodeSelect"
1✔
56
                  />
1✔
57
                </b-input-group>
1✔
58
              </div>
1✔
59
              <div
1✔
60
                v-else
61
                class="flex-grow-1 ps-0 ps-md-2 pe-0 pe-md-2 fullsubject"
1✔
62
              >
63
                <label class="me-2">Subject:</label>
1✔
64
                <b-form-input v-model="editmessage.subject" size="lg" />
1✔
65
                <label class="me-2">Post type:</label>
1✔
66
                <b-form-select
1✔
67
                  v-model="editmessage.type"
1✔
68
                  :options="typeOptions"
1✔
69
                  class="type me-1"
1✔
70
                  size="lg"
1✔
71
                />
1✔
72
              </div>
1✔
73
            </div>
1✔
74
            <ModDiff
1✔
75
              v-else-if="editreview && oldSubject && newSubject"
1✔
76
              :old="oldSubject"
1✔
77
              :new="newSubject"
1✔
78
              class="fw-bold"
1✔
79
            />
1✔
80
            <div v-else :class="subjectClass + ' fw-bold'">
1✔
81
              <span
1!
82
                v-if="message.matchedon && message.matchedon.type === 'Vector'"
✔
83
                class="highlight"
×
NEW
84
                >{{ eSubject }}</span
×
85
              >
1✔
86
              <Highlighter
1✔
87
                v-else-if="message.matchedon"
1✔
88
                :search-words="[String(message.matchedon.word)]"
1✔
89
                :text-to-highlight="eSubject"
1✔
90
                highlight-class-name="highlight"
1✔
91
                auto-escape
1✔
92
              />
1✔
93
              <span v-else>
1✔
94
                {{ eSubject }}
166✔
95
              </span>
1✔
96
              <span v-if="message.location" class="text-muted small ms-1">{{
1✔
97
                message.location.name
160✔
98
              }}</span>
1✔
99
              <span
1✔
100
                v-if="
1✔
101
                  message.availableinitially && message.availableinitially > 1
102
                "
103
                class="small text-info ms-1"
1✔
104
              >
105
                <b-badge
1✔
106
                  v-if="message.availableinitially === message.availablenow"
1✔
107
                  variant="info"
1✔
108
                >
109
                  {{ message.availablenow }} available
1✔
110
                </b-badge>
1✔
111
                <b-badge v-else variant="info">
1✔
112
                  {{ message.availableinitially }} available initially,
1✔
113
                  {{ message.availablenow ? message.availablenow : 0 }} now
1!
114
                </b-badge>
1✔
115
              </span>
1✔
116
            </div>
1✔
117
            <span
1✔
118
              v-if="pending && (editreview || review)"
14✔
119
              class="badge bg-warning text-dark ms-1"
14✔
120
            >
14✔
121
              Pending
122
            </span>
14✔
123
            <!-- Approved-by is shown by MessageHistory with resolved name -->
1✔
124
            <div v-if="message.deadline" class="text-danger small">
1✔
125
              Deadline: end {{ dateonly(message.deadline) }}
2✔
126
            </div>
1✔
127
            <div v-if="message.deliverypossible" class="text-info small">
1✔
128
              Delivery possible
129
            </div>
2✔
130
            <MessageHistory
1✔
131
              :id="message.id"
1✔
132
              :message="message"
1✔
133
              modinfo
1✔
134
              display-message-link
1✔
135
            />
1✔
136
            <div
1✔
137
              v-if="homegroup && groupid && groupid !== homegroupids[0]"
1✔
138
              class="small text-danger"
1✔
139
            >
1✔
140
              Possibly should be on {{ homegroup }}
1✔
141
              <span v-if="!homegroupontn"> but group not on TN </span>
1!
142
            </div>
1✔
143
            <div v-if="otherGroups.length > 0" class="small text-muted">
1✔
144
              Also on:
1✔
145
              <span v-for="(g, idx) in otherGroups" :key="g.groupid"
1✔
146
                >{{
147
                  groupStore.get(g.groupid)?.namedisplay ||
1!
148
                  'Group ' + g.groupid
149
                }}<span v-if="idx < otherGroups.length - 1">, </span></span
1!
150
              >
1✔
151
            </div>
1✔
152
            <ModMessageDuplicate
1✔
153
              v-for="(duplicate, index) in duplicates"
1✔
154
              :key="'duplicate-' + duplicate.id + '-' + index"
1✔
155
              :messageid="duplicate.id"
1✔
156
            />
1✔
157
            <ModMessageCrosspost
1✔
158
              v-for="crosspost in crossposts"
1✔
159
              :key="'crosspost-' + crosspost.id"
1✔
160
              :messageid="crosspost.id"
1✔
161
            />
1✔
162
            <div v-if="expanded">
1✔
163
              <ModMessageRelated
1✔
164
                v-for="related in message.related"
1✔
165
                :key="'related-' + related.id"
1✔
166
                :messageid="related.id"
1✔
167
              />
1✔
168
            </div>
1✔
169
          </div>
1✔
170
          <div class="d-flex flex-shrink-0">
1✔
171
            <div
1✔
172
              v-if="summary && message && fromUser"
1✔
173
              class="text-info fw-bold me-2 text-truncate d-inline-block"
1✔
174
              style="max-width: 8rem"
1✔
175
            >
176
              {{ fromUser.displayname }}
1✔
177
              <span v-if="fromUser.deleted" class="badge bg-danger ms-1">
1✔
178
                Account deleted
179
              </span>
1✔
180
            </div>
1✔
181
            <span
1✔
182
              v-else-if="fromUser && fromUser.deleted"
4✔
183
              class="badge bg-danger me-2 align-self-center"
4✔
184
            >
4✔
185
              Account deleted
186
            </span>
4✔
187
            <div v-if="expanded" class="d-flex">
1✔
188
              <div class="d-flex flex-column align-content-end">
1✔
189
                <b-button v-if="!editing" variant="white" @click="startEdit">
1✔
190
                  <v-icon icon="pen" /><span class="d-none d-sm-inline">
1✔
191
                    Edit</span
1✔
192
                  >
1✔
193
                </b-button>
1✔
194
                <b-button
1✔
195
                  v-if="message.source === 'Email'"
1✔
196
                  variant="white"
1✔
197
                  class="mt-2"
1✔
198
                  @click="showEmailSourceModal = true"
1✔
199
                >
200
                  <v-icon icon="book-open" /><span class="d-none d-sm-inline">
1✔
201
                    View Email Source</span
1✔
202
                  >
1✔
203
                </b-button>
1✔
204
                <SpinButton
1✔
205
                  v-if="contextGroup?.collection === 'Approved'"
1✔
206
                  class="mt-2"
1✔
207
                  variant="white"
1✔
208
                  icon-name="reply"
1✔
209
                  confirm
1✔
210
                  @handle="backToPending"
1✔
211
                  ><span class="d-none d-sm-inline"
1✔
212
                    >Back to Pending</span
1✔
213
                  ></SpinButton
1✔
214
                >
1✔
215
              </div>
1✔
216
              <div class="ms-2">
1✔
217
                <b-button
1✔
218
                  v-if="summary"
1✔
219
                  variant="white"
1✔
220
                  @click="expanded = !expanded"
1✔
221
                >
222
                  <v-icon icon="caret-up" />
1✔
223
                </b-button>
1✔
224
              </div>
1✔
225
            </div>
1✔
226
            <div v-else>
1✔
227
              <b-button variant="white" @click="expanded = !expanded">
1✔
228
                <v-icon icon="caret-down" />
1✔
229
              </b-button>
1✔
230
            </div>
1✔
231
          </div>
1✔
232
        </div>
1✔
233
      </b-card-header>
1✔
234
      <b-card-body v-if="expanded" class="p-1 p-md-2">
1✔
235
        <b-row>
1✔
236
          <b-col cols="12" lg="5">
1✔
237
            <NoticeMessage
1✔
238
              v-if="message.type === 'Other'"
1✔
239
              variant="danger"
1✔
240
              class="mb-2"
1✔
241
            >
1✔
242
              This message needs editing so that we know what kind of post it
243
              is.
244
            </NoticeMessage>
1✔
245
            <div v-if="expanded">
1✔
246
              <NoticeMessage
1✔
247
                v-if="message.outcomes && message.outcomes.length"
1✔
248
                class="mb-1"
1✔
249
              >
250
                {{ message.outcomes[0].outcome.toUpperCase() }}
1✔
251
                at
252
                {{ datetimeshort(message.outcomes[0].timestamp) }}
1✔
253
              </NoticeMessage>
1✔
254
              <div v-if="message.heldby">
1✔
255
                <NoticeMessage variant="warning" class="mb-2">
1✔
256
                  <p v-if="me.id === heldbyId">
1✔
257
                    You held this. Other people will see a warning to check with
258
                    you before releasing it. If you release it, it will stay in
259
                    Pending.
260
                  </p>
1✔
261
                  <p v-else>
1✔
262
                    Held by <strong>{{ heldbyName }}</strong
1✔
263
                    >. Please check with them before releasing it.
1✔
264
                  </p>
1✔
265
                  <ModMessageButton
1✔
266
                    :messageid="message.id"
1✔
267
                    :groupid="groupid"
1✔
268
                    variant="warning"
1✔
269
                    icon="play"
1✔
270
                    release
1✔
271
                    label="Release"
1✔
272
                  />
1✔
273
                </NoticeMessage>
1✔
274
              </div>
1✔
275
            </div>
1✔
276
            <div v-if="!fromUser && fromUserId" class="mb-2">
1!
277
              <Spinner :size="20" />
1✔
278
            </div>
1✔
279
            <div v-if="fromUser">
1✔
280
              <NoticeMessage
1✔
281
                v-if="fromUser.deleted"
1✔
282
                variant="danger"
1✔
283
                class="mb-2"
1✔
284
              >
1✔
285
                This user has deleted their account. You may wish to handle this
286
                message accordingly as they will not be able to respond.
287
              </NoticeMessage>
1✔
288
              <ModComments
1✔
289
                :userid="fromUserId"
1✔
290
                @update-comments="updateComments"
1✔
291
              />
1✔
292
              <ModSpammer v-if="fromUser.spammer" :userid="fromUserId" />
1✔
293
              <NoticeMessage
1✔
294
                v-if="fromUser.activedistance > 50"
1✔
295
                variant="warning"
1✔
296
                class="mb-2"
1✔
297
              >
1✔
298
                This freegler recently active on groups
299
                {{ fromUser.activedistance }} miles apart.
1✔
300
              </NoticeMessage>
1✔
301
            </div>
1✔
302
            <NoticeMessage v-if="outsideUK" variant="warning" class="mb-2">
1✔
303
              This message may be from outside the UK ({{ position.lat }},
1✔
304
              {{ position.lng }}), which means it might be a scam. Please check
1✔
305
              carefully.
306
            </NoticeMessage>
1✔
307
            <NoticeMessage
1✔
308
              v-if="message.spamreason"
1✔
309
              variant="warning"
1✔
310
              class="mb-2"
1✔
311
            >
312
              {{ message.spamreason }}
1✔
313
            </NoticeMessage>
1✔
314
            <NoticeMessage
1✔
315
              v-if="
1✔
316
                pending &&
317
                membership &&
318
                membership.ourpostingstatus === 'MODERATED'
319
              "
320
              variant="info"
1✔
321
              class="mb-2"
1✔
322
            >
1✔
323
              This member is <strong>Moderated</strong> — their posts need
1✔
324
              approval before going live.
325
            </NoticeMessage>
1✔
326
            <div
1✔
327
              v-if="
1✔
328
                message.microvolunteering && message.microvolunteering.length
329
              "
330
            >
331
              <ModMessageMicroVolunteering
1✔
332
                v-for="m in message.microvolunteering"
1✔
333
                :key="'microvolunteering-' + m.id"
1✔
334
                :messageid="message.id"
1✔
335
                :microvolunteeringid="m.id"
1✔
336
                class="mb-1"
1✔
337
              />
1✔
338
              <b-button
1✔
339
                v-if="pending"
1✔
340
                v-b-tooltip.html
1✔
341
                variant="white"
1✔
342
                size="sm"
1✔
343
                title="<p>We ask members to review messages as part of microvolunteering.  When members have proven that they are reliable at microvolunteering, they may be shown Pending messages, so you may see their views here.  This can also show for Pending messages for reposts. <p>You can control whether specific members can do microvolunteering - click on their user id.</p>"
1✔
344
              >
345
                <v-icon icon="info-circle" /> What's this?
1✔
346
              </b-button>
1✔
347
              <b-button
1✔
348
                v-else
349
                v-b-tooltip.html
1✔
350
                variant="white"
1✔
351
                size="sm"
1✔
352
                title="<p>We ask members to review messages as part of microvolunteering.  Messages will be sent for review if a couple of members think they shouldn't be on Freegle.</p><p>Consider whether you (or the original poster) can edit the message to improve it.</p><p>You can control whether specific members can do microvolunteering - click on their user id.</p>"
1✔
353
              >
354
                <v-icon icon="info-circle" /> What's this?
1✔
355
              </b-button>
1✔
356
              <p class="text-muted small" />
1✔
357
            </div>
1✔
358
            <ModMessageWorry
1✔
359
              v-if="
1✔
360
                message.worry?.length ||
361
                message.groups?.some(
362
                  (g) => g.contentcheck_reasons && g.contentcheck_reasons.length
363
                )
364
              "
365
              :messageid="message.id"
1✔
366
            />
1✔
367
            <div v-if="expanded">
1✔
368
              <!-- eslint-disable-next-line -->
1✔
369
              <b-form-textarea
1✔
370
                v-if="editing"
1✔
371
                v-model="editmessage.textbody"
1✔
372
                rows="8"
1✔
373
                class="mb-3"
1✔
374
              />
1✔
375
              <div v-else-if="editreview">
1✔
376
                <template v-if="oldBody || newBody">
1✔
377
                  <h4>Differences:</h4>
1✔
378
                  <ModDiff
1✔
379
                    class="mb-3 rounded border border-warning p-2 preline forcebreak fw-bold"
1✔
380
                    :old="oldBody"
1✔
381
                    :new="newBody"
1✔
382
                  />
1✔
383
                  <h4>New version:</h4>
1✔
384
                  <div
1✔
385
                    class="mb-3 rounded border border-success p-2 preline forcebreak fw-bold"
1✔
386
                  >
387
                    {{ newBody }}
1✔
388
                  </div>
1✔
389
                </template>
1✔
390
                <p v-else class="text-muted">
1✔
391
                  Subject changed (see above). No body text changes.
392
                </p>
1✔
393
              </div>
1✔
394
              <div
1✔
395
                v-else-if="!eBody"
1✔
396
                class="mb-3 rounded border p-2 preline forcebreak fw-bold"
1✔
397
              >
398
                <em>This message is blank.</em>
1✔
399
              </div>
1✔
400
              <div
1✔
401
                v-else
402
                class="mb-3 rounded border p-2 preline forcebreak fw-bold"
1✔
403
              >
404
                <span
1!
NEW
405
                  v-if="
×
406
                    message.matchedon && message.matchedon.type === 'Vector'
407
                  "
408
                  class="highlight"
×
NEW
409
                  >{{ eBody }}</span
×
410
                >
1✔
411
                <Highlighter
1✔
412
                  v-else-if="message.matchedon"
1✔
413
                  :search-words="[String(message.matchedon.word)]"
1✔
414
                  :text-to-highlight="eBody"
1✔
415
                  highlight-class-name="highlight"
1✔
416
                  auto-escape
1✔
417
                />
1✔
418
                <span v-else>
1✔
419
                  {{ eBody }}
68✔
420
                </span>
1✔
421
              </div>
1✔
422
              <b-alert
1✔
423
                v-if="isBulk"
1✔
424
                :model-value="true"
1✔
425
                variant="info"
1✔
426
                class="mb-3"
1✔
427
              >
428
                <strong>
1✔
429
                  <v-icon icon="boxes-stacked" /> Bulk clearance —
1✔
430
                  {{ message.bulkcount }} item{{
1✔
431
                    message.bulkcount === 1 ? '' : 's'
1✔
432
                  }}.
1✔
433
                </strong>
1✔
434
                This is a single post on the group, but when a member opens it
435
                they can see each item and turn on the ones they'd like (and how
436
                many).
437
                <b-button
1✔
438
                  variant="link"
1✔
439
                  class="p-0 ms-1 align-baseline"
1✔
440
                  data-testid="bulk-preview-btn"
1✔
441
                  @click="showBulkPreview = true"
1✔
442
                >
1✔
443
                  See how members see it
444
                </b-button>
1✔
445
              </b-alert>
1✔
446
              <div v-if="attachments?.length" class="w-100 d-flex flex-wrap">
1✔
447
                <div
1✔
448
                  v-for="attachment in attachments"
1✔
449
                  :key="'attachment-' + attachment.id"
1✔
450
                  :class="{
1✔
451
                    'd-inline': true,
452
                    'pe-1': true,
453
                    addedImage: imageAdded(attachment.id),
454
                    removeImage: imageRemoved(attachment.id),
455
                  }"
456
                >
457
                  <div class="addedMessage ps-2 fw-bold text-success">
1✔
458
                    Added
459
                  </div>
78✔
460
                  <div class="removedMessage ps-2 fw-bold text-warning">
1✔
461
                    Removed
462
                  </div>
78✔
463
                  <ModPhoto
1✔
464
                    :messageid="messageid"
1✔
465
                    :attachmentid="attachment.id"
1✔
466
                  />
1✔
467
                </div>
1✔
468
              </div>
1✔
469
              <MessageReplyInfo
1✔
470
                v-if="!pending || (message.replies && message.replies.length)"
1!
471
                :message="message"
1✔
472
                class="d-inline"
1✔
473
              />
1✔
474
            </div>
1✔
475
          </b-col>
1✔
476
          <b-col cols="12" lg="3">
1✔
477
            <MessageMap
1✔
478
              v-if="group && position"
1✔
479
              :centerat="{ lat: group.lat, lng: group.lng }"
1✔
480
              :position="{ lat: position.lat, lng: position.lng }"
1✔
481
              locked
1✔
482
              :boundary="group.poly || group.polyofficial"
1✔
483
              :height="150"
1✔
484
            />
1✔
485
          </b-col>
1✔
486
          <b-col cols="12" lg="3">
1✔
487
            <div
1✔
488
              class="rounded border border-info p-2 d-flex justify-content-between flex-wrap"
1✔
489
            >
490
              <ModMessageUserInfo
1✔
491
                v-if="fromUser && message.groups && message.groups.length"
1✔
492
                :message="message"
1✔
493
                :userid="fromUserId"
1✔
494
                modinfo
1✔
495
                :groupid="groupid"
1✔
496
              />
1✔
497
              <div v-else-if="fromUserId && !fromUser">
1✔
498
                <Spinner :size="20" />
1✔
499
              </div>
1✔
500
              <div v-else>
1✔
501
                <NoticeMessage
1✔
502
                  v-if="
1✔
503
                    message.myrole === 'Non-member' ||
504
                    message.myrole === 'Member'
505
                  "
506
                  variant="danger"
1✔
507
                >
1✔
508
                  Sender only available to mods.
509
                </NoticeMessage>
1✔
510
                <NoticeMessage v-else variant="danger">
1✔
511
                  Can't identify sender. Could have been purged but perhaps a
512
                  bug.
513
                </NoticeMessage>
1✔
514
              </div>
1✔
515
            </div>
1✔
516
            <div class="d-flex justify-content-between flex-wrap">
1✔
517
              <b-button
1✔
518
                v-if="fromUser && !fromUser.ljuserid && !fromUser.tnuserid"
1✔
519
                variant="link"
1✔
520
                @click="toggleMail"
1✔
521
              >
522
                <span v-if="showMailSettings">
1✔
523
                  <v-icon icon="cog" />
1✔
524
                  <span class="d-inline d-sm-none"> Hide </span>
1✔
525
                  <span class="d-none d-sm-inline"> Hide mail settings </span>
1✔
526
                </span>
1✔
527
                <span v-else>
1✔
528
                  <v-icon icon="cog" />
1✔
529
                  <span class="d-inline d-sm-none"> Settings </span>
1✔
530
                  <span class="d-none d-sm-inline"> Show mail settings </span>
1✔
531
                </span>
1✔
532
              </b-button>
1✔
533
              <b-button
1✔
534
                v-if="fromUser && fromUser.emails && fromUser.emails.length"
1!
535
                variant="link"
1✔
536
                @click="showEmails = !showEmails"
1✔
537
              >
538
                <span v-if="showEmails">
1✔
539
                  <span class="d-inline d-sm-none"> Hide </span>
1✔
540
                  <span class="d-none d-sm-inline">
1✔
541
                    Hide
542
                    {{ pluralise('email', fromUser.emails.length, true) }}
1✔
543
                  </span>
1✔
544
                </span>
1✔
545
                <span v-else>
1✔
546
                  <span class="d-inline d-sm-none">
1✔
547
                    <v-icon icon="envelope" />
1✔
548
                    {{ pluralise('email', fromUser.emails.length, true) }}
1✔
549
                  </span>
1✔
550
                  <span class="d-none d-sm-inline">
1✔
551
                    Show
552
                    {{ pluralise('email', fromUser.emails.length, true) }}
1✔
553
                  </span>
1✔
554
                </span>
1✔
555
              </b-button>
1✔
556
              <b-button variant="link" @click="showActions = !showActions">
1✔
557
                <v-icon icon="hammer" />
1✔
558
                <span v-if="showActions">
1✔
559
                  <span class="d-inline d-sm-none"> Hide </span>
1✔
560
                  <span class="d-none d-sm-inline"> Hide actions </span>
1✔
561
                </span>
1✔
562
                <span v-else>
1✔
563
                  <span class="d-inline d-sm-none"> Actions </span>
1✔
564
                  <span class="d-none d-sm-inline"> Show actions </span>
1✔
565
                </span>
1✔
566
              </b-button>
1✔
567
            </div>
1✔
568
            <SettingsGroup
1✔
569
              v-if="
1✔
570
                showMailSettings &&
571
                membership &&
572
                message.groups &&
573
                message.groups.length
574
              "
575
              :emailfrequency="membership.emailfrequency"
1✔
576
              :membership-m-t="membership"
1✔
577
              class="border border-info mt-2 p-1"
1✔
578
              :userid="fromUserId"
1✔
579
              @update:emailfrequency="settingsChange('emailfrequency', $event)"
1✔
580
              @update:eventsallowed="settingsChange('eventsallowed', $event)"
1✔
581
              @update:volunteeringallowed="
1✔
582
                settingsChange('volunteeringallowed', $event)
583
              "
584
            />
1✔
585
            <div v-if="showEmails && fromUser">
1!
586
              <div v-for="email in fromUser.emails" :key="email.id">
1✔
587
                {{ email.email }} <v-icon v-if="email.preferred" icon="star" />
1✔
588
              </div>
1✔
589
            </div>
1✔
590
            <ModMemberActions
1✔
591
              v-if="showActions && message.groups && message.groups.length"
1!
592
              :userid="fromUserId"
1✔
593
              :groupid="groupid"
1✔
594
              @commentadded="updateComments"
1✔
595
            />
1✔
596
          </b-col>
1✔
597
        </b-row>
1✔
598
        <div
1✔
599
          v-if="review && message.groups && message.groups.length"
1✔
600
          class="mt-1"
1✔
601
        >
602
          <b-alert
1✔
603
            v-if="contextGroup?.collection === 'Pending'"
1✔
604
            variant="info"
1✔
605
            show
1✔
606
          >
607
            <v-icon icon="info-circle" /> Post now in <em>Pending</em>.
1✔
608
          </b-alert>
1✔
609
          <b-alert
1✔
610
            v-if="contextGroup?.collection === 'Approved'"
1✔
611
            variant="info"
1✔
612
            show
1✔
613
          >
614
            <v-icon icon="info-circle" /> Post now in <em>Approved</em>.
1✔
615
          </b-alert>
1✔
616
        </div>
1✔
617
        <b-row v-if="uploading" class="bg-white">
1✔
618
          <b-col class="p-0">
1✔
619
            <OurUploader v-model="attachments" type="Message" multiple />
1✔
620
          </b-col>
1✔
621
        </b-row>
1✔
622
      </b-card-body>
1✔
623
      <b-card-footer v-if="!noactions && expanded">
1✔
624
        <div v-if="message.heldby && heldbyId !== myid">
1✔
625
          This message is held by someone else. The buttons are hidden so you
626
          don't click them by accident. Please check with them before releasing
627
          the message.
628
        </div>
2✔
629
        <NoticeMessage
1✔
630
          v-else-if="!editing && !message.lat && !message.lng"
1✔
631
          variant="danger"
1✔
632
          class="mb-2"
1✔
633
        >
1✔
634
          This message needs editing so that we know where it is.
635
        </NoticeMessage>
1✔
636
        <div
1✔
637
          v-if="
1✔
638
            pending &&
639
            (!message.heldby || (message.heldby && heldbyId === myid)) &&
640
            !editing
641
          "
642
          class="text-end mb-1"
1✔
643
        >
644
          <b-button variant="danger" @click="spamReport">
1✔
645
            <v-icon icon="ban" /> Report Spammer
1✔
646
          </b-button>
1✔
647
        </div>
1✔
648
        <ModMessageButtons
1✔
649
          v-if="
1✔
650
            (!message.heldby || (message.heldby && heldbyId === myid)) &&
651
            !editing
652
          "
653
          :messageid="message.id"
1✔
654
          :groupid="groupid"
1✔
655
          :modconfigid="configid"
1✔
656
          :editreview="editreview"
1✔
657
          :cantpost="membership && membership.ourpostingstatus === 'PROHIBITED'"
1✔
658
        />
1✔
659
        <b-button
1✔
660
          v-if="editing"
1✔
661
          variant="secondary"
1✔
662
          class="me-auto"
1✔
663
          @click="photoAdd"
1✔
664
        >
665
          <v-icon icon="camera" />&nbsp;Add photo
1✔
666
        </b-button>
1✔
667
        <b-button v-if="editing" variant="white" @click="cancelEdit">
1✔
668
          <v-icon icon="times" /> Cancel
1✔
669
        </b-button>
1✔
670
        <b-button v-if="editing" variant="primary" @click="save">
1✔
671
          <v-icon v-if="saving" icon="sync" class="text-success fa-spin" />
1✔
672
          <v-icon v-else-if="saved" icon="check" class="text-success" />
1✔
673
          <v-icon v-else icon="save" />
1✔
674
          Save
675
        </b-button>
1✔
676
      </b-card-footer>
1✔
677
    </b-card>
1✔
678
    <ModMessageEmailModal
1✔
679
      v-if="showEmailSourceModal && message.source === 'Email'"
1!
680
      :id="message.id"
1✔
681
      @hidden="showEmailSourceModal = false"
1✔
682
    />
1✔
683
    <ModSpammerReport
1✔
684
      v-if="showSpamModal"
1✔
685
      ref="spamConfirm"
1✔
686
      :userid="fromUserId"
1✔
687
      :safelist="false"
1✔
688
    />
1✔
689
    <ModBulkPreviewModal
1✔
690
      v-if="showBulkPreview"
1✔
691
      :messageid="message.id"
1✔
692
      @hidden="showBulkPreview = false"
1✔
693
    />
1✔
694
    <div ref="bottom" />
1✔
695
  </div>
1✔
696
</template>
697

698
<script setup>
699
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
1✔
700
import Highlighter from 'vue-highlight-words'
1✔
701

702
import { useAuthStore } from '~/stores/auth'
1✔
703
import { useGroupStore } from '~/stores/group'
1✔
704
import { useLocationStore } from '~/stores/location'
1✔
705
import { useMessageStore } from '~/stores/message'
1✔
706
import { useUserStore } from '~/stores/user'
1✔
707

708
import { setupKeywords } from '~/composables/useKeywords'
1✔
709
import { useMemberStore } from '~/stores/member'
1✔
710
import { useModConfigStore } from '~/stores/modconfig'
1✔
711
import { useMiscStore } from '~/stores/misc'
1✔
712
import { SUBJECT_REGEX } from '~/constants'
1✔
713
import { useMe } from '~/composables/useMe'
1✔
714
import { useModMe } from '~/composables/useModMe'
1✔
715

716
import { useModGroupStore } from '@/stores/modgroup'
1✔
717

718
import { twem } from '~/composables/useTwem'
1✔
719

720
const props = defineProps({
1✔
721
  messageid: {
722
    type: Number,
723
    required: true,
724
  },
725
  editreview: {
726
    type: Boolean,
727
    required: false,
728
    default: false,
729
  },
730
  noactions: {
731
    type: Boolean,
732
    required: false,
733
    default: false,
734
  },
735
  summary: {
736
    type: Boolean,
737
    required: false,
738
    default: false,
739
  },
740
  review: {
741
    type: Boolean,
742
    required: false,
743
    default: false,
744
  },
745
  search: {
746
    type: String,
747
    required: false,
748
    default: null,
749
  },
750
  next: {
751
    type: Number,
752
    required: false,
753
    default: null,
754
  },
755
  nextAfterRemoved: {
756
    type: Number,
757
    required: false,
758
    default: null,
759
  },
760
  contextGroupid: {
761
    type: Number,
762
    required: false,
763
    default: null,
764
  },
765
})
766

767
const emit = defineEmits(['destroy'])
1✔
768

769
const authStore = useAuthStore()
1✔
770
const groupStore = useGroupStore()
1✔
771
const locationStore = useLocationStore()
1✔
772
const memberStore = useMemberStore()
1✔
773
const messageStore = useMessageStore()
1✔
774
const miscStore = useMiscStore()
1✔
775
const modconfigStore = useModConfigStore()
1✔
776
const modGroupStore = useModGroupStore()
1✔
777
const userStore = useUserStore()
1✔
778

779
const message = computed(() => messageStore.byId(props.messageid))
1✔
780

781
watch(
1✔
782
  () => props.messageid,
1✔
783
  async (id) => {
1✔
784
    if (id && !messageStore.byId(id)) {
83!
785
      await messageStore.fetch(id)
×
786
    }
×
787
  },
1✔
788
  { immediate: true }
1✔
789
)
1✔
790

791
// V2 API returns fromuser as a numeric ID. Resolve from user store reactively.
1✔
792
const fromUserId = computed(() => {
1✔
793
  if (!message.value) return null
83!
794
  const fu = message.value.fromuser
83✔
795
  if (!fu) return null
83!
796
  return typeof fu === 'number' ? fu : fu.id
83!
797
})
83✔
798

799
// Trigger a fetch if the user isn't in the store yet.
1✔
800
watch(
1✔
801
  fromUserId,
1✔
802
  (uid) => {
1✔
803
    if (uid && !userStore.byId(uid)) {
83!
804
      userStore.fetch(uid)
×
805
    }
×
806
  },
1✔
807
  { immediate: true }
1✔
808
)
1✔
809

810
const fromUser = computed(() => {
1✔
811
  if (!fromUserId.value) return null
83!
812
  return userStore.byId(fromUserId.value) || null
83!
813
})
83✔
814
const { typeOptions } = setupKeywords()
1✔
815
const { me, myid } = useMe()
1✔
816
const { myModGroups, myModGroup } = useModMe()
1✔
817

818
const top = ref(null)
1✔
819
const bottom = ref(null)
1✔
820
const spamConfirm = ref(null)
1✔
821

822
const saving = ref(false)
1✔
823
const saved = ref(false)
1✔
824
const showEmailSourceModal = ref(false)
1✔
825
const showSpamModal = ref(false)
1✔
826
const showBulkPreview = ref(false)
1✔
827

828
// A bulk "clearance" offer carries a structured catalogue of items.
1✔
829
const isBulk = computed(
1✔
830
  () =>
1✔
831
    (message.value?.bulkcount || 0) > 0 ||
78✔
832
    (message.value?.bulkitems?.length || 0) > 0
78!
833
)
1✔
834
const showMailSettings = ref(false)
1✔
835
const showActions = ref(false)
1✔
836
const showEmails = ref(false)
1✔
837
const editing = ref(false)
1✔
838
const expanded = ref(false)
1✔
839
const editgroup = ref(null)
1✔
840
const uploading = ref(false)
1✔
841
const attachments = ref([])
1✔
842
const homegroup = ref(null)
1✔
843
const homegroupontn = ref(false)
1✔
844
const homegroupids = ref([])
1✔
845
const historyGroups = reactive({})
1✔
846
const editmessage = ref(false)
1✔
847

848
const groupid = computed(() => {
1✔
849
  // Use contextual groupid prop if provided (multi-group support),
83✔
850
  // otherwise fall back to first group.
83✔
851
  if (props.contextGroupid) return props.contextGroupid
83✔
852

853
  if (message.value && message.value.groups && message.value.groups.length) {
83✔
854
    return message.value.groups[0].groupid
77✔
855
  }
77!
856
  return 0
×
857
})
×
858

859
const messageGroup = computed(() => {
1✔
860
  return groupid.value || null
78!
861
})
78✔
862

863
// Get the group info for the contextual group (multi-group support).
1✔
864
const contextGroup = computed(() => {
1✔
865
  if (!message.value?.groups?.length) return null
78!
866
  const gid = parseInt(groupid.value)
78✔
867
  return (
78✔
868
    message.value.groups.find((g) => parseInt(g.groupid) === gid) ||
78!
NEW
869
    message.value.groups[0]
×
870
  )
78✔
871
})
78✔
872

873
// Other groups this message is on (for multi-group indicator).
1✔
874
const otherGroups = computed(() => {
1✔
875
  if (!message.value?.groups) return []
83!
876
  const gid = parseInt(groupid.value)
83✔
877
  return message.value.groups.filter((g) => parseInt(g.groupid) !== gid)
83✔
878
})
83✔
879

880
const messageHistory = computed(() => {
1✔
881
  return fromUser.value?.messagehistory || []
83✔
882
})
83✔
883

884
const group = computed(() => {
1✔
885
  let ret = null
78✔
886

887
  if (messageGroup.value) {
78✔
888
    ret = myModGroups.value.find((g) => parseInt(g.id) === messageGroup.value)
78✔
889
  }
78✔
890

891
  return ret
78✔
892
})
78✔
893

894
const position = computed(() => {
1✔
895
  let ret = null
78✔
896

897
  if (message.value) {
78✔
898
    if (message.value.location) {
78✔
899
      // This is what we put in for message submitted on FD.
74✔
900
      ret = message.value.location
74✔
901
    } else if (message.value.lat || message.value.lng) {
78✔
902
      // This happens for TN messages
2✔
903
      ret = {
2✔
904
        lat: message.value.lat,
2✔
905
        lng: message.value.lng,
2✔
906
      }
2✔
907
    }
2✔
908
  }
78✔
909

910
  return ret
78✔
911
})
78✔
912

913
const outsideUK = computed(() => {
1✔
914
  return (
78✔
915
    position.value &&
78✔
916
    (position.value.lng < -16 ||
76✔
917
      position.value.lat < 49 ||
76✔
918
      position.value.lng > 4 ||
76✔
919
      position.value.lat > 64)
76✔
920
  )
78✔
921
})
78✔
922

923
const pending = computed(() => {
1✔
924
  return hasCollection('Pending')
83✔
925
})
83✔
926

927
const eSubject = computed(() => {
1✔
928
  if (!message.value) return ''
82!
929
  return twem(message.value.subject)
82✔
930
})
82✔
931

932
const eBody = computed(() => {
1✔
933
  if (!message.value) return ''
70!
934
  return twem(message.value.textbody)
70✔
935
})
70✔
936

937
// Handle heldby as either numeric ID (Go API) or object (PHP API).
1✔
938
const heldbyId = computed(() => {
1✔
939
  if (!message.value) return null
3!
940
  const h = message.value.heldby
3✔
941
  if (!h) return null
3!
942
  return Number.isInteger(h) ? h : h.id
3!
943
})
3✔
944

945
const heldbyName = computed(() => {
1✔
946
  if (!message.value) return ''
2!
947
  const h = message.value.heldby
2✔
948
  if (!h) return ''
2!
949
  if (Number.isInteger(h)) {
2!
950
    const user = userStore.byId(h)
×
951
    return user?.displayname || ''
×
952
  }
×
953
  return h.displayname || ''
2!
954
})
2✔
955

956
const membership = computed(() => {
1✔
957
  let ret = null
78✔
958

959
  if (groupid.value && fromUser.value?.memberships) {
78✔
960
    ret = fromUser.value.memberships.find((g) => g.groupid === groupid.value)
78✔
961
  }
78✔
962

963
  return ret
78✔
964
})
78✔
965

966
const configid = computed(() => {
1✔
967
  let id = null
83✔
968

969
  // Look up configid from authStore.groups (always populated from session)
83✔
970
  // rather than relying on modGroupStore.list[].mysettings which may not be
83✔
971
  // populated yet due to a race condition with fetchGroupMT().
83✔
972
  if (groupid.value && authStore.groups) {
83✔
973
    const sessionGroup = authStore.groups.find(
83✔
974
      (g) => parseInt(g.groupid) === parseInt(groupid.value)
83✔
975
    )
83✔
976
    if (sessionGroup?.configid) {
83✔
977
      id = sessionGroup.configid
82✔
978
    }
82✔
979
  }
83✔
980

981
  if (!id) {
83✔
982
    const defaultConfig = modconfigStore.configs.find(
1✔
983
      (config) => config.default
1✔
984
    )
1✔
985
    id = defaultConfig?.id
1✔
986
  }
1✔
987

988
  return id
83✔
989
})
83✔
990

991
const modconfig = ref(null)
1✔
992

993
watch(
1✔
994
  configid,
1✔
995
  async (id) => {
1✔
996
    if (id) {
83✔
997
      modconfig.value = await modconfigStore.fetchById(id)
83✔
998
    }
83✔
999
  },
1✔
1000
  { immediate: true }
1✔
1001
)
1✔
1002

1003
const subjectClass = computed(() => {
1✔
1004
  let ret = 'text-success'
162✔
1005

1006
  if (message.value && modconfig.value && modconfig.value.coloursubj) {
162✔
1007
    ret =
80✔
1008
      message.value.subject?.match &&
80✔
1009
      message.value.subject.match(modconfig.value.subjreg)
80✔
1010
        ? 'text-success'
80✔
1011
        : 'text-danger'
80✔
1012
  }
80✔
1013

1014
  return ret
162✔
1015
})
162✔
1016

1017
const oldSubject = computed(() => {
1✔
1018
  if (!props.editreview || !message.value || !message.value.edits) {
6✔
1019
    return null
1✔
1020
  }
1✔
1021

1022
  // Edits are in descending time order.
5✔
1023
  let oldest = null
5✔
1024

1025
  message.value.edits.forEach((edit) => {
5✔
1026
    if (edit.reviewrequired && edit.oldsubject) {
5✔
1027
      oldest = edit.oldsubject
1✔
1028
    }
1✔
1029
  })
5✔
1030

1031
  return oldest
5✔
1032
})
5✔
1033

1034
const newSubject = computed(() => {
1✔
1035
  if (!props.editreview || !message.value || !message.value.edits) {
1!
1036
    return null
×
1037
  }
×
1038

1039
  // Find the newest and oldest texts; intermediates are just confusing.
1✔
1040
  // Edits are in descending time order.
1✔
1041
  let newest = null
1✔
1042

1043
  message.value.edits.forEach((edit) => {
1✔
1044
    if (edit.reviewrequired) {
1✔
1045
      if (edit.newsubject && !newest) {
1✔
1046
        newest = edit.newsubject
1✔
1047
      }
1✔
1048
    }
1✔
1049
  })
1✔
1050

1051
  return newest
1✔
1052
})
1✔
1053

1054
const oldBody = computed(() => {
1✔
1055
  if (!props.editreview || !message.value || !message.value.edits) {
6✔
1056
    return null
1✔
1057
  }
1✔
1058

1059
  // Edits are in descending time order.
5✔
1060
  let oldest = null
5✔
1061

1062
  message.value.edits.forEach((edit) => {
5✔
1063
    if (edit.reviewrequired && edit.oldtext) {
5!
1064
      oldest = edit.oldtext
×
1065
    }
×
1066
  })
5✔
1067

1068
  return oldest
5✔
1069
})
5✔
1070

1071
const newBody = computed(() => {
1✔
1072
  if (!props.editreview || !message.value || !message.value.edits) {
6✔
1073
    return null
1✔
1074
  }
1✔
1075

1076
  // Find the newest and oldest texts; intermediates are just confusing.
5✔
1077
  // Edits are in descending time order.
5✔
1078
  let newest = message.value.textbody
5✔
1079

1080
  message.value.edits.forEach((edit) => {
5✔
1081
    if (edit.reviewrequired) {
5✔
1082
      if (edit.newtext && !newest) {
5!
1083
        newest = edit.newtext
×
1084
      }
×
1085
    }
5✔
1086
  })
5✔
1087

1088
  return newest
5✔
1089
})
5✔
1090

1091
const duplicateAge = computed(() => {
1✔
1092
  let ret = 31
×
1093
  let check = false
×
1094
  if (!message.value?.groups) return null
×
1095

1096
  message.value.groups.forEach((g) => {
×
1097
    const grp = myModGroup(g.groupid)
×
1098

1099
    // console.log("duplicateAge group", group?.settings?.duplicates)
×
1100
    if (
×
1101
      grp &&
×
1102
      grp.settings &&
×
1103
      grp.settings.duplicates && // TODO: MT group does not have settings
×
1104
      grp.settings.duplicates.check
×
1105
    ) {
×
1106
      check = true
×
1107
      const msgtype = message.value.type.toLowerCase()
×
1108
      ret = Math.min(ret, grp.settings.duplicates[msgtype])
×
1109
    }
×
1110
  })
×
1111

1112
  // console.log('checkHistory duplicateAge',check,ret)
×
1113
  return check ? ret : null
×
1114
})
×
1115

1116
const crossposts = computed(() => {
1✔
1117
  return checkHistory(false)
83✔
1118
})
83✔
1119

1120
const duplicates = computed(() => {
1✔
1121
  return checkHistory(true)
83✔
1122
})
83✔
1123

1124
const memberGroupIds = computed(() => {
1✔
1125
  return fromUser.value?.memberships
7✔
1126
    ? fromUser.value.memberships.map((g) => g.id)
7✔
1127
    : []
7!
1128
})
7✔
1129

1130
watch(
1✔
1131
  () => props.summary,
1✔
1132
  (newVal) => {
1✔
1133
    if (newVal && expanded.value) {
×
1134
      expanded.value = false
×
1135
    } else if (!newVal && !expanded.value) {
×
1136
      expanded.value = true
×
1137
    }
×
1138
  }
×
1139
)
1✔
1140

1141
// When expanding, force-fetch the full message to get all attachments.
1✔
1142
// The list endpoint only returns the first image for performance.
1✔
1143
watch(expanded, async (newVal) => {
1✔
1144
  if (newVal && message.value?.id) {
78✔
1145
    const fresh = await messageStore.fetch(message.value.id, true)
78✔
1146
    if (fresh?.attachments) {
78!
1147
      attachments.value = fresh.attachments
×
1148
    }
×
1149
  }
78✔
1150
})
78✔
1151

1152
watch(
1✔
1153
  () => props.nextAfterRemoved,
1✔
1154
  (newVal) => {
1✔
1155
    if (message.value && newVal === message.value.id) {
×
1156
      // This message is the one after one which has just been removed.  Make sure the top is visible.
×
1157
      bottom.value.scrollIntoView()
×
1158
      top.value.scrollIntoView(true)
×
1159
    }
×
1160
  }
×
1161
)
1✔
1162

1163
watch(
1✔
1164
  messageHistory,
1✔
1165
  async (newVal) => {
1✔
1166
    // We want to ensure that we have the groups for any message history, so that we can use them in canonSubj.
83✔
1167
    // console.log('ModMessage: watch messageHistory',newVal)
83✔
1168
    await newVal.forEach(async function (histMsg) {
83✔
1169
      if (!historyGroups[histMsg.groupid]) {
×
1170
        historyGroups[histMsg.groupid] = await modGroupStore.fetchIfNeedBeMT(
×
1171
          histMsg.groupid
×
1172
        )
×
1173
      }
×
1174
    })
83✔
1175
  },
1✔
1176
  { immediate: true }
1✔
1177
)
1✔
1178

1179
onMounted(() => {
1✔
1180
  expanded.value = !props.summary
83✔
1181
  if (message.value) {
83✔
1182
    attachments.value = Array.isArray(message.value.attachments)
83✔
1183
      ? message.value.attachments
83✔
1184
      : []
83!
1185
    findHomeGroup()
83✔
1186

1187
    // Fetch heldby user if message is held (Go API returns numeric ID).
83✔
1188
    if (message.value.heldby && Number.isInteger(message.value.heldby)) {
83!
1189
      userStore.fetch(message.value.heldby)
×
1190
    }
×
1191
  }
83✔
1192
})
83✔
1193

1194
onBeforeUnmount(() => {
1✔
1195
  if (message.value) {
×
1196
    emit('destroy', message.value.id, props.next)
×
1197
  }
×
1198
})
×
1199

1200
function updateComments() {
1✔
1201
  // fromUser is a computed from userStore.byId, so it auto-updates when the store changes.
1✔
1202
  // Force a re-fetch to get updated comments.
1✔
1203
  if (fromUserId.value) {
1✔
1204
    userStore.fetch(fromUserId.value)
1✔
1205
  }
1✔
1206
}
1✔
1207

1208
function imageAdded(id) {
86✔
1209
  let ret = false
86✔
1210

1211
  if (props.editreview && message.value && message.value.edits) {
86✔
1212
    message.value.edits.forEach((edit) => {
7✔
1213
      const n = edit.newimages ? JSON.parse(edit.newimages) : []
7✔
1214
      const o = edit.oldimages ? JSON.parse(edit.oldimages) : []
7✔
1215
      if (n.includes(id) && !o.includes(id)) {
7✔
1216
        ret = true
3✔
1217
      }
3✔
1218
    })
7✔
1219
  }
7✔
1220

1221
  return ret
86✔
1222
}
86✔
1223

1224
function imageRemoved(id) {
86✔
1225
  let ret = false
86✔
1226

1227
  if (props.editreview && message.value && message.value.edits) {
86✔
1228
    message.value.edits.forEach((edit) => {
7✔
1229
      const n = edit.newimages ? JSON.parse(edit.newimages) : []
7✔
1230
      const o = edit.oldimages ? JSON.parse(edit.oldimages) : []
7✔
1231
      if (!n.includes(id) && o.includes(id)) {
7✔
1232
        ret = true
3✔
1233
      }
3✔
1234
    })
7✔
1235
  }
7✔
1236

1237
  return ret
86✔
1238
}
86✔
1239

1240
function hasCollection(coll) {
83✔
1241
  let ret = false
83✔
1242

1243
  if (message.value?.groups) {
83✔
1244
    message.value.groups.forEach((grp) => {
83✔
1245
      if (grp.collection === coll) {
88✔
1246
        ret = true
78✔
1247
      }
78✔
1248
    })
83✔
1249
  }
83✔
1250

1251
  return ret
83✔
1252
}
83✔
1253

1254
function postcodeSelect(pc) {
2✔
1255
  if (editing.value && editmessage.value) {
2✔
1256
    editmessage.value.location = pc
2✔
1257
  } else {
2!
1258
    message.value.location = pc
×
1259
  }
×
1260
}
2✔
1261

1262
function startEdit() {
8✔
1263
  // Clone so that store refetches don't overwrite the edit state.
8✔
1264
  editmessage.value = JSON.parse(JSON.stringify(message.value))
8✔
1265
  editing.value = true
8✔
1266
  miscStore.modtoolsediting = true
8✔
1267
  editmessage.value.groups.forEach((grp) => {
8✔
1268
    editgroup.value = grp.groupid
8✔
1269
  })
8✔
1270
}
8✔
1271

1272
async function save() {
3✔
1273
  saving.value = true
3✔
1274

1275
  try {
3✔
1276
    const attids = []
3✔
1277

1278
    if (Array.isArray(attachments.value)) {
3✔
1279
      for (const att of attachments.value) {
3✔
1280
        attids.push(att.id)
3✔
1281
      }
3✔
1282
    }
3✔
1283

1284
    if (editmessage.value.item && editmessage.value.location) {
3✔
1285
      // Well-structured message
2✔
1286
      await messageStore.patch({
2✔
1287
        id: editmessage.value.id,
2✔
1288
        msgtype: editmessage.value.type,
2✔
1289
        item: editmessage.value.item.name,
2✔
1290
        location: editmessage.value.location.name,
2✔
1291
        attachments: attids,
2✔
1292
        textbody: editmessage.value.textbody,
2✔
1293
      })
2✔
1294
    } else {
3✔
1295
      // Not well-structured
1✔
1296
      await messageStore.patch({
1✔
1297
        id: editmessage.value.id,
1✔
1298
        msgtype: editmessage.value.type,
1✔
1299
        subject: editmessage.value.subject,
1✔
1300
        attachments: attids,
1✔
1301
        textbody: editmessage.value.textbody,
1✔
1302
      })
1✔
1303
    }
1✔
1304

1305
    let alreadyon = false
3✔
1306

1307
    editmessage.value.groups.forEach((g) => {
3✔
1308
      if (g.groupid === editgroup.value) {
3✔
1309
        alreadyon = true
3✔
1310
      }
3✔
1311
    })
3✔
1312

1313
    if (!alreadyon) {
3!
1314
      await messageStore.move({
×
1315
        id: editmessage.value.id,
×
1316
        groupid: editgroup.value,
×
1317
      })
×
1318
    }
×
1319

1320
    editing.value = false
3✔
1321
    miscStore.modtoolsediting = false
3✔
1322
  } finally {
3✔
1323
    saving.value = false
3✔
1324
  }
3✔
1325
}
3✔
1326

1327
function settingsChange(param, val) {
1✔
1328
  const params = {
1✔
1329
    userid: fromUserId.value,
1✔
1330
    groupid: groupid.value,
1✔
1331
  }
1✔
1332
  params[param] = val
1✔
1333
  memberStore.update(params)
1✔
1334
}
1✔
1335

1336
async function toggleMail() {
1✔
1337
  showMailSettings.value = !showMailSettings.value
1✔
1338

1339
  if (showMailSettings.value && fromUserId.value) {
1✔
1340
    // Get the user into the store for SettingsGroup.
1✔
1341
    await userStore.fetch(fromUserId.value)
1✔
1342
  }
1✔
1343
}
1✔
1344

1345
function canonSubj(message) {
166✔
1346
  let subj = message.subject
166✔
1347
  if (!subj) subj = ''
166!
1348
  const grp = historyGroups[message.groupid]
166✔
1349

1350
  if (grp && grp.settings && grp.settings.keywords) {
166!
1351
    // TODO: MT group does not have settings
×
1352
    const keyword =
×
1353
      message.type === 'Offer'
×
1354
        ? grp.settings.keywords.offer
×
1355
        : grp.settings.keywords.wanted
×
1356
    if (keyword) {
×
1357
      subj = subj.replace(keyword, message.type.toUpperCase())
×
1358
    }
×
1359
  }
×
1360

1361
  if (subj.toLocaleLowerCase) {
166✔
1362
    subj = subj.toLocaleLowerCase()
166✔
1363

1364
    // Remove any group tag
166✔
1365
    subj = subj.replace(/^\[.*?\](.*)/, '$1')
166✔
1366

1367
    // Remove duplicate spaces
166✔
1368
    subj = subj.replace(/\s+/g, ' ')
166✔
1369

1370
    subj = subj.trim()
166✔
1371

1372
    const matches = SUBJECT_REGEX.exec(subj)
166✔
1373
    if (matches?.length > 2) {
166✔
1374
      // Well-formed - remove the location.
162✔
1375
      subj = matches[1] + ': ' + matches[2].toLowerCase().trim()
162✔
1376
    }
162✔
1377
  }
166✔
1378

1379
  return subj
166✔
1380
}
166✔
1381

1382
function checkHistory(duplicateCheck) {
166✔
1383
  const ret = []
166✔
1384
  if (!message.value) return ret
166!
1385
  const subj = canonSubj(message.value)
166✔
1386
  const dupids = []
166✔
1387
  const crossids = []
166✔
1388

1389
  if (fromUser.value?.messagehistory) {
166!
1390
    fromUser.value.messagehistory.forEach((histMsg) => {
×
1391
      if (
×
1392
        histMsg.id !== message.value.id &&
×
1393
        duplicateAge.value &&
×
1394
        histMsg.daysago <= duplicateAge.value
×
1395
      ) {
×
1396
        // if( duplicateCheck) console.log('checkHistory check',histMsg)
×
1397
        if (canonSubj(histMsg) === subj) {
×
1398
          // No point displaying any group tag in the duplicate.
×
1399
          histMsg.subject = histMsg.subject.replace(/\[.*\](.*)/, '$1')
×
1400

1401
          // Check whether there are groups in common.
×
1402
          const groupsInCommon = message.value.groups
×
1403
            .map((g) => g.groupid)
×
1404
            .filter((g) => g === histMsg.groupid).length
×
1405

1406
          const key = histMsg.id + '-' + histMsg.arrival
×
1407

1408
          if (duplicateCheck && groupsInCommon) {
×
1409
            // Same group - so this is a duplicate
×
1410
            if (!dupids[key]) {
×
1411
              dupids[key] = true
×
1412
              ret.push(histMsg)
×
1413
            }
×
1414
          } else if (!duplicateCheck && !groupsInCommon) {
×
1415
            // Different group - so this is a crosspost.
×
1416
            if (!crossids[key]) {
×
1417
              crossids[key] = true
×
1418
              ret.push(histMsg)
×
1419
            }
×
1420
          }
×
1421
        }
×
1422
      }
×
1423
    })
×
1424
  }
×
1425
  // if( duplicateCheck) console.log('checkHistory duplicateCheck',ret)
166✔
1426
  return ret
166✔
1427
}
166✔
1428

1429
function photoAdd() {
1✔
1430
  // Flag that we're uploading.  This will trigger the render of the filepond instance and subsequently the
1✔
1431
  // init callback below.
1✔
1432
  uploading.value = true
1✔
1433
}
1✔
1434

1435
async function findHomeGroup() {
83✔
1436
  if (!message.value) return
83!
1437

1438
  // Prefer nearby groups from the message API (computed from original unblurred coords).
83✔
1439
  const msgGroups = message.value.location?.groupsnear
83✔
1440
  if (msgGroups && msgGroups.length) {
83✔
1441
    homegroup.value = msgGroups[0].namedisplay
2✔
1442
    homegroupontn.value = msgGroups[0].ontn
2✔
1443
    homegroupids.value = msgGroups.map((g) => parseInt(g.id))
2✔
1444
    return
2✔
1445
  }
2✔
1446

1447
  // Fallback: look up from (blurred) lat/lng via location API.
81✔
1448
  if (message.value.lat && message.value.lng) {
83✔
1449
    const loc = await locationStore.fetch({
79✔
1450
      lat: message.value.lat,
79✔
1451
      lng: message.value.lng,
79✔
1452
    })
79✔
1453

1454
    if (loc && loc.groupsnear && loc.groupsnear.length) {
79✔
1455
      homegroup.value = loc.groupsnear[0].namedisplay
11✔
1456
      homegroupontn.value = loc.groupsnear[0].ontn
11✔
1457
      homegroupids.value = loc.groupsnear.map((g) => parseInt(g.id))
11✔
1458
    }
11✔
1459
  }
79✔
1460
}
83✔
1461

1462
function cancelEdit() {
1✔
1463
  editing.value = false
1✔
1464
  miscStore.modtoolsediting = false
1✔
1465

1466
  // Fetch the message again to revert any changes.
1✔
1467
  messageStore.fetch(message.value.id)
1✔
1468
}
1✔
1469

1470
async function backToPending(callback) {
1✔
1471
  await messageStore.backToPending(message.value.id)
1✔
1472
  callback()
1✔
1473
}
1✔
1474

1475
function spamReport() {
1✔
1476
  showSpamModal.value = true
1✔
1477
  spamConfirm.value?.show()
1!
1478
}
1✔
1479
</script>
1480

1481
<style scoped lang="scss">
1482
@import 'bootstrap/scss/functions';
1483
@import 'bootstrap/scss/variables';
1484
@import 'bootstrap/scss/mixins/_breakpoints';
1485
//@import 'color-vars';
1486

1487
.type {
1488
  max-width: 150px;
1489
}
1490

1491
.location {
1492
  max-width: 250px;
1493
}
1494

1495
.item-name-input {
1496
  /* Cap growth on wide desktop so the field doesn't stretch the whole row.
1497
     flex-grow-1 keeps it filling available space up to this cap. */
1498
  max-width: 500px;
1499
}
1500

1501
.fullsubject {
1502
  display: grid;
1503
  grid-template-columns: 200px 1fr;
1504

1505
  grid-column: 2 / 3;
1506

1507
  label {
1508
    grid-column: 1 / 2;
1509
  }
1510

1511
  @include media-breakpoint-down(md) {
1512
    grid-template-columns: 1fr;
1513
  }
1514
}
1515

1516
.addedMessage,
1517
.removedMessage {
1518
  display: none;
1519
}
1520

1521
.addedImage {
1522
  border: 1px solid $color-green--dark !important;
1523

1524
  .addedMessage {
1525
    display: block !important;
1526
  }
1527
}
1528

1529
.removedImage {
1530
  border: 1px solid $color-red--dark !important;
1531

1532
  .removedMessage {
1533
    display: block !important;
1534
  }
1535
}
1536
</style>
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc