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

Freegle / iznik-nuxt3 / e295f783-924d-482f-957e-d7dd24de88dd

05 Feb 2026 11:03PM UTC coverage: 44.827% (+0.02%) from 44.812%
e295f783-924d-482f-957e-d7dd24de88dd

push

circleci

CircleCI Auto-merge
Auto-merge master to production after successful tests - Original commit: fix: Restore scrollable prop on MessageEditModal to keep footer visible

3691 of 8388 branches covered (44.0%)

Branch coverage included in aggregate %.

1673 of 3578 relevant lines covered (46.76%)

76.55 hits per line

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

60.73
/components/PostMapAndList.vue
1
<template>
2
  <div>
3
    <h2 class="visually-hidden">Map of offers and wanteds</h2>
49✔
4
    <PostMap
5
      v-if="initialBounds"
30!
6
      v-model:ready="mapready"
7
      v-model:bounds="bounds"
8
      v-model:show-groups="showGroups"
9
      v-model:moved="mapMoved"
10
      v-model:zoom="zoom"
11
      v-model:centre="centre"
12
      v-model:loading="loading"
13
      :show-isochrones="showIsochrones"
14
      :initial-bounds="initialBounds"
15
      :height-fraction="heightFraction"
16
      :min-zoom="minZoom"
17
      :max-zoom="maxZoom"
18
      :post-zoom="10"
19
      :force-messages="forceMessages"
20
      :type="selectedType"
21
      :search="search"
22
      :show-many="showMany"
23
      :groupid="selectedGroup"
24
      :region="region"
25
      :can-hide="canHide"
26
      :isochrone-override="isochroneOverride"
27
      :authorityid="authorityid"
28
      @searched="searched"
29
      @messages="messagesChanged($event)"
49✔
30
      @groups="groupsChanged($event)"
49✔
31
      @idle="$emit('idle', $event)"
49✔
32
    />
33
    <div v-observe-visibility="mapVisibilityChanged" />
34
    <div class="rest">
35
      <div
36
        v-if="showClosestGroups && closestGroups?.length && !mapHidden"
121✔
37
        class="mb-1 border p-2 bg-white"
38
      >
39
        <h2 class="visually-hidden">Nearby communities</h2>
2✔
40
        <div class="d-flex flex-wrap justify-content-center gap-2">
41
          <JoinWithConfirm
42
            v-for="g in closestGroups"
43
            :id="g.id"
44
            :key="'group-' + g.id"
45
            :name="g.namedisplay"
46
            size="sm"
47
            variant="primary"
48
          />
49
        </div>
50
      </div>
51
      <div v-if="showGroups" class="bg-white pt-3">
30✔
52
        <div v-if="showRegions">
25!
53
          <div class="d-flex flex-wrap justify-content-center pb-4">
54
            <div v-for="r in regions" :key="r" class="p-0 mt-2 ml-2 mr-2">
55
              <b-button variant="secondary" :to="'/explore/region/' + r">
56
                {{ r }}
57
              </b-button>
58
            </div>
59
          </div>
60
        </div>
61
        <div v-if="showGroupList">
25!
62
          <h2 class="visually-hidden">List of communities</h2>
×
63
          <AdaptiveMapGroup
64
            v-for="groupid in groupids"
65
            :id="groupid"
66
            :key="'adaptivegroup-' + groupid"
67
          />
68
        </div>
69
        <p
70
          class="text-center mt-2 header--size5 text--medium-large-highlight community__text"
71
        >
72
          <!-- eslint-disable-next-line -->
73
          Need help?  Go <nuxt-link no-prefetch to="/help">here</nuxt-link>.
76✔
74
        </p>
75
        <p
76
          v-if="showStartMessage"
25!
77
          class="text-center mt-2 header--size5 text--medium-large-highlight community__text"
78
        >
79
          <!-- eslint-disable-next-line -->
80
          If there's no community for your area, would you like to start one? <ExternalLink href="mailto:newgroups@ilovefreegle.org">Get in touch!</ExternalLink>
20✔
81
        </p>
82
      </div>
83
      <div v-else>
84
        <NoticeMessage v-if="noneFound">
5!
85
          <p>
×
86
            Sorry, we didn't find anything. Things come and go quickly, though,
87
            so you could try later. Or you could:
88
          </p>
89
          <GiveAsk class="bg-info" />
5!
90
        </NoticeMessage>
91
        <div
92
          v-else-if="!postsVisible && messagesOnMap?.length"
93
          class="d-flex justify-content-center mt-1 mb-1"
94
        >
95
          <NoticeMessage variant="info">
96
            <v-icon icon="angle-double-down" class="pulsate" />
×
97
            Scroll down to see
98
            <span v-if="search"
×
99
              >results for "<strong>{{ search }}</strong
×
100
              >"</span
101
            ><span v-else>the posts</span>.
×
102
            <v-icon icon="angle-double-down" class="pulsate" />
103
          </NoticeMessage>
104
        </div>
105
        <h2 class="visually-hidden">List of wanteds and offers</h2>
7✔
106
        <MessageList
107
          v-if="updatedMessagesOnMap || messagesOnMap.length"
15✔
108
          :key="'messagelist-' + infiniteId"
109
          v-model:visible="postsVisible"
110
          v-model:none="noneFound"
111
          :search="search"
112
          show-counts-unseen
113
          :selected-group="selectedGroup"
114
          :selected-type="selectedType"
115
          :selected-sort="selectedSort"
116
          :messages-for-list="filteredMessages"
117
          :loading="loading"
118
          :jobs="jobs"
119
          :first-seen-message="firstSeenMessage"
120
        />
121
      </div>
122
    </div>
123
  </div>
124
</template>
125
<script setup>
126
import { ref, computed, watch, defineAsyncComponent } from 'vue'
127
import { useGroupStore } from '~/stores/group'
128
import { useAuthStore } from '~/stores/auth'
129
import { useMiscStore } from '~/stores/misc'
130
import { getDistance } from '~/composables/useMap'
131
import { MAX_MAP_ZOOM } from '~/constants'
132
import { useIsochroneStore } from '~/stores/isochrone'
133

134
import JoinWithConfirm from '~/components/JoinWithConfirm'
135
import MessageList from '~/components/MessageList'
136
const AdaptiveMapGroup = defineAsyncComponent(() => import('./MapGroup'))
19✔
137
const ExternalLink = defineAsyncComponent(() => import('./ExternalLink'))
138
const NoticeMessage = defineAsyncComponent(() => import('./NoticeMessage'))
139
const GiveAsk = defineAsyncComponent(() => import('./GiveAsk'))
140
const PostMap = defineAsyncComponent(() => import('~/components/PostMap'))
141

142
const props = defineProps({
143
  initialBounds: {
144
    type: Array,
145
    required: true,
146
  },
147
  startOnGroups: {
148
    type: Boolean,
149
    required: false,
150
    default: false,
151
  },
152
  forceMessages: {
153
    type: Boolean,
154
    required: false,
155
    default: false,
156
  },
157
  initialGroupIds: {
158
    type: Array,
159
    required: false,
160
    default() {
161
      return []
162
    },
163
  },
164
  region: {
165
    type: String,
166
    required: false,
167
    default: null,
168
  },
169
  showStartMessage: {
170
    type: Boolean,
171
    required: false,
172
    default: false,
173
  },
174
  jobs: {
175
    type: Boolean,
176
    required: false,
177
    default: false,
178
  },
179
  minZoom: {
180
    type: Number,
181
    required: false,
182
    default: 5,
183
  },
184
  maxZoom: {
185
    type: Number,
186
    required: false,
187
    default: MAX_MAP_ZOOM,
188
  },
189
  showMany: {
190
    type: Boolean,
191
    required: false,
192
    default: true,
193
  },
194
  canHide: {
195
    type: Boolean,
196
    required: false,
197
    default: false,
198
  },
199
  search: {
200
    type: String,
201
    required: false,
202
    default: null,
203
  },
204
  selectedType: {
205
    type: String,
206
    required: false,
207
    default: 'All',
208
  },
209
  selectedGroup: {
210
    type: Number,
211
    required: false,
212
    default: 0,
213
  },
214
  selectedSort: {
215
    type: String,
216
    required: false,
217
    default: 'Unseen',
218
  },
219
  showClosestGroups: {
220
    type: Boolean,
221
    required: false,
222
    default: true,
223
  },
224
  isochroneOverride: {
225
    type: Object,
226
    required: false,
227
    default: null,
228
  },
229
  authorityid: {
230
    type: Number,
231
    required: false,
232
    default: null,
233
  },
234
})
235

236
const emit = defineEmits([
237
  'update:selectedGroup',
238
  'update:messagesOnMapCount',
239
  'idle',
240
])
241

242
// Store instances
243
const miscStore = useMiscStore()
244
const groupStore = useGroupStore()
245
const authStore = useAuthStore()
246
const isochroneStore = useIsochroneStore()
247
const me = computed(() => authStore.user)
248

249
// Refs from setup
250
const showGroups = ref(props.startOnGroups)
251
const groupids = ref(props.initialGroupIds)
252

253
// Data properties
254
const heightFraction = ref(4)
255
const loading = ref(false)
256
const bounds = ref(null)
257
const zoom = ref(null)
258
const centre = ref(null)
259
const mapready = ref(process.server)
260
const mapVisible = ref(true)
261
const postsVisible = ref(true)
262
const mapMoved = ref(false)
263
const updatedMessagesOnMap = ref(null)
264
const firstSeenMessage = ref(null)
265
const infiniteId = ref(+new Date())
266
const noneFound = ref(false)
267
const lastFilteredIds = ref(null)
268
// Lock in the sort order once messages are loaded. This prevents the list from
269
// jumping around as messages are marked as seen. Only re-sort when the actual
270
// set of message IDs changes (new messages arrive or messages are removed).
271
const lockedSortOrder = ref(null)
272

273
// Computed properties
274
const showIsochrones = computed(() => {
275
  if (props.isochroneOverride) {
19!
276
    return true
277
  } else {
278
    return browseView.value === 'nearby'
279
  }
280
})
281

282
const mapHidden = computed(() => {
283
  return miscStore?.get('hidepostmap')
1!
284
})
285

286
const browseView = computed(() => {
287
  return me.value?.settings?.browseView
19!
288
    ? me.value.settings.browseView
289
    : 'nearby'
290
})
291

292
const messagesOnMap = computed({
293
  get() {
294
    if (updatedMessagesOnMap.value !== null) {
19!
295
      // We have been told by the map to show a specific set of messages.
296
      return updatedMessagesOnMap.value
19!
297
    } else {
298
      // See if we have some from the isochrone, which we will have fetched in browse/index.
299
      return isochroneStore?.messageList ?? []
19!
300
    }
301
  },
302
  set(newVal) {
303
    updatedMessagesOnMap.value = newVal
×
304
  },
305
})
306

307
const regions = computed(() => {
308
  const regions = []
24✔
309

310
  try {
24✔
311
    const allGroups = groupStore?.list
24!
312

313
    for (const ix in allGroups) {
24✔
314
      const group = allGroups[ix]
48✔
315

316
      if (group.region && !regions.includes(group.region)) {
48!
317
        regions.push(group.region)
318
      }
319
    }
320

321
    regions.sort()
24✔
322
  } catch (e) {
323
    console.error('Exception', e)
×
324
  }
325

326
  return regions
24✔
327
})
328

329
const messagesForList = computed(() => {
330
  let msgs = []
19✔
331

332
  msgs = sortedMessagesOnMap.value
19✔
333

334
  if (props.selectedGroup) {
19!
335
    msgs = msgs.filter((m) => m.groupid === props.selectedGroup)
336
  }
337

338
  return msgs
339
})
340

341
const filteredMessages = computed(() => {
342
  let ret = []
19✔
343

344
  if (!props.search) {
19!
345
    ret = messagesForList.value
346
  } else {
347
    // We are searching.
348
    const messages = messagesForList.value
349

350
    messages.forEach((message) => {
351
      if (message) {
×
352
        // Pass whether the message has been freegled, which in this case is returned as the outcomes in the
353
        // message.
354
        let successful = false
×
355

356
        if (message.outcomes && message.outcomes.length) {
×
357
          for (const outcome of message.outcomes) {
×
358
            if (outcome.outcome === 'Taken' || outcome.outcome === 'Received') {
359
              successful = true
360
            }
361
          }
362
        }
363

364
        message.successful = successful
×
365

366
        if (
367
          !message.deleted &&
×
368
          (!message.outcomes || message.outcomes.length === 0)
369
        ) {
370
          ret.push(message)
371
        }
372
      }
373
    })
374
  }
375

376
  return ret
377
})
378

379
// Helper function to sort messages
380
function sortMessages(messages) {
19✔
381
  return messages.slice().sort((a, b) => {
19✔
382
    if (props.selectedSort === 'Unseen') {
2!
383
      // Unseen messages first, then by descending date/time. But we don't want to treat successful posts as
384
      // unseen otherwise they bob up to the top.
385
      const aunseen = a.unseen && !a.successful
2✔
386
      const bunseen = b.unseen && !b.successful
4✔
387

388
      if (aunseen && !bunseen) {
2!
389
        return -1
390
      } else if (!aunseen && bunseen) {
2!
391
        return 1
392
      } else {
393
        return new Date(b.arrival).getTime() - new Date(a.arrival).getTime()
394
      }
395
    } else {
396
      // Descending date/time.
397
      return new Date(b.arrival).getTime() - new Date(a.arrival).getTime()
×
398
    }
399
  })
400
}
401

402
const sortedMessagesOnMap = computed(() => {
19✔
403
  if (!messagesOnMap.value) {
19!
404
    return []
×
405
  }
406

407
  const messages = messagesOnMap.value
19✔
408

409
  // If we have a locked sort order, use it to maintain stable positions
410
  if (lockedSortOrder.value) {
19✔
411
    const messageMap = new Map(messages.map((m) => [m.id, m]))
1✔
412
    return lockedSortOrder.value
1✔
413
      .filter((id) => messageMap.has(id))
414
      .map((id) => messageMap.get(id))
415
  }
416

417
  // No locked order yet - return freshly sorted messages
418
  return sortMessages(messages)
18✔
419
})
420

421
const showRegions = computed(() => {
422
  // We want to show the regions if we're zoomed out, or for SSR = SEO.
423
  return process.server || zoom.value < 7
424
})
425

426
const showGroupList = computed(() => {
427
  // We want to show the list of groups for SSR = SEO, or if we are not showing the regions (because we're
428
  // zoomed out)
429
  return process.server || !showRegions.value
430
})
431

432
const closestGroups = computed(() => {
433
  const ret = []
20✔
434
  const distances = {}
435

436
  if (centre.value) {
20✔
437
    const allGroups = groupStore.list
1✔
438

439
    for (const ix in allGroups) {
1✔
440
      const group = allGroups[ix]
2✔
441

442
      if (group) {
2!
443
        // See if the group is showing in the map area.
444
        if (
445
          bounds.value.contains([group.lat, group.lng]) ||
446
          ((group.altlat || group.altlng) &&
2✔
447
            bounds.value.contains([group.altlat, group.altlng]))
448
        ) {
449
          // Are we already a member?
450
          const member = authStore.member(group.id)
451

452
          if (!member) {
453
            // Visible group?
454
            if (group.onmap && group.publish) {
455
              // How far away?
456
              distances[group.id] = getDistance(
457
                [centre.value.lat, centre.value.lng],
458
                [group.lat, group.lng]
459
              )
460

461
              // Allowed to show?
462
              if (
463
                !group.showjoin ||
2!
464
                distances[group.id] <= group.showjoin * 1609.34
465
              ) {
466
                ret.push(group)
467
              } else if (group.altlat || group.altlng) {
×
468
                // A few groups have two centres because they are large.
469
                distances[group.id] = getDistance(
470
                  [centre.value.lat, centre.value.lng],
471
                  [group.altlat, group.altlng]
472
                )
473

474
                if (distances[group.id] <= group.showjoin * 1609.34) {
×
475
                  ret.push(group)
476
                }
477
              }
478
            }
479
          }
480
        }
481
      }
482
    }
483

484
    ret.sort((a, b) => {
1✔
485
      return distances[a.id] - distances[b.id]
486
    })
487
  }
488

489
  return ret.slice(0, 3)
20✔
490
})
491

492
// Watchers
493
// Update the locked sort order when the set of message IDs changes
494
watch(
19✔
495
  messagesOnMap,
496
  (newMessages) => {
497
    if (!newMessages?.length) {
19✔
498
      lockedSortOrder.value = null
18✔
499
      return
18✔
500
    }
501

502
    const currentIds = new Set(newMessages.map((m) => m.id))
1✔
503

504
    // Check if we need to update the locked order: no locked order yet, or IDs have changed
505
    const needsUpdate =
506
      !lockedSortOrder.value ||
1!
507
      lockedSortOrder.value.length !== currentIds.size ||
508
      !lockedSortOrder.value.every((id) => currentIds.has(id))
509

510
    if (needsUpdate) {
19✔
511
      // Sort and lock in the order
512
      const sorted = sortMessages(newMessages)
1✔
513
      lockedSortOrder.value = sorted.map((m) => m.id)
1✔
514
    }
515
  },
516
  { immediate: true }
517
)
518

519
watch(
520
  () => isochroneStore.messageList,
521
  (newList) => {
522
    if (updatedMessagesOnMap.value && newList?.length) {
×
523
      const unseenMap = new Map(newList.map((m) => [m.id, m.unseen]))
×
524
      let changed = false
×
525

526
      updatedMessagesOnMap.value.forEach((m) => {
×
527
        const newUnseen = unseenMap.get(m.id)
×
528
        if (newUnseen !== undefined && m.unseen !== newUnseen) {
×
529
          m.unseen = newUnseen
530
          changed = true
531
        }
532
      })
533

534
      if (changed) {
×
535
        updatedMessagesOnMap.value = [...updatedMessagesOnMap.value]
536
      }
537
    }
538
  },
539
  { deep: true }
540
)
541

542
watch(
543
  filteredMessages,
544
  (newVal) => {
545
    // We want to save the first message we have seen so that we show a message when we have scrolled down to it.
546
    // We want that message to stay there until the page is reloaded, even as we read the messages and the seen
547
    // state of the messages changes.
548
    if (firstSeenMessage.value === null) {
19!
549
      for (const message of newVal) {
19✔
550
        if (!message.unseen) {
3!
551
          firstSeenMessage.value = message.id
×
552
          break
×
553
        }
554
      }
555
    }
556

557
    // Only reset the infinite scroll when the actual list of message IDs changes,
558
    // not when other properties (like unseen status) change. This prevents the
559
    // scroll position from being lost when messages are marked as seen.
560
    const newIds = JSON.stringify(newVal.map((m) => m.id))
19✔
561
    if (lastFilteredIds.value !== newIds) {
19✔
562
      lastFilteredIds.value = newIds
563
      infiniteId.value++
564
    }
565
  },
566
  { immediate: true }
567
)
568

569
// Reset the locked sort order when the sort option changes
570
watch(
571
  () => props.selectedSort,
572
  () => {
573
    lockedSortOrder.value = null
×
574
  }
575
)
576

577
// Methods
578
function messagesChanged(messages) {
1✔
579
  if (messages) {
1!
580
    let changed = false
1✔
581

582
    if (!messages || !messagesOnMap.value) {
1!
583
      changed = true
×
584
    } else {
585
      const oldids = messagesOnMap.value.map((m) => m.id)
1✔
586
      const newids = messages.map((m) => m.id)
587

588
      if (JSON.stringify(oldids) !== JSON.stringify(newids)) {
1!
589
        changed = true
590
      }
591
    }
592

593
    if (changed) {
1!
594
      messagesOnMap.value = messages
595
      infiniteId.value++
596
    }
597

598
    emit('update:messagesOnMapCount', messagesOnMap.value.length)
599
  }
600
}
601

602
function groupsChanged(groupidsParam) {
6✔
603
  groupids.value = groupidsParam
6✔
604
}
605

606
function mapVisibilityChanged(visible) {
24✔
607
  mapVisible.value = visible
24✔
608
}
609

610
function searched() {
×
611
  // When we've searched on a place, we want to reset the selected group otherwise we won't show anything.
612
  emit('update:selectedGroup', 0)
×
613
}
614
</script>
615
<style scoped lang="scss">
616
@import 'bootstrap/scss/functions';
617
@import 'bootstrap/scss/variables';
618
@import 'bootstrap/scss/mixins/_breakpoints';
619

620
.postcode {
621
  position: absolute;
622
  top: 0px;
623
  right: 0px;
624
  z-index: 20000;
625
}
626

627
.community__text {
628
  /* Need to override the h2 as it has higher specificity */
629
  color: #212529 !important;
630
}
631

632
.shrink {
633
  width: unset;
634
}
635

636
.dense {
637
  .btn {
638
    max-width: 300px;
639
    text-overflow: ellipsis;
640
  }
641
}
642
</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