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

Freegle / iznik-nuxt3 / 4c25d233-8590-4982-8de8-c68e7c82bfee

26 Dec 2025 09:53AM UTC coverage: 42.284%. First build
4c25d233-8590-4982-8de8-c68e7c82bfee

Pull #131

circleci

edwh
Add kconv gem for Ruby 3.4 compatibility

The kconv gem was removed from Ruby's stdlib in 3.4 but is
required by Fastlane's CFPropertyList dependency. This fixes
the auto-submit-ios job failure.
Pull Request #131: Add reply flow state machine and comprehensive Playwright tests

3370 of 8043 branches covered (41.9%)

Branch coverage included in aggregate %.

16 of 17 new or added lines in 1 file covered. (94.12%)

1754 of 4075 relevant lines covered (43.04%)

59.47 hits per line

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

75.0
/components/MessageReplySection.vue
1
<template>
2
  <div>
3
    <div v-if="!fromme" class="grey p-2">
66!
4
      <EmailValidator
5
        v-if="!me"
66✔
6
        ref="emailValidatorRef"
7
        v-model:email="stateMachine.email.value"
45✔
8
        v-model:valid="stateMachine.emailValid.value"
45✔
9
        size="lg"
10
        label="Your email address:"
11
        class="test-email-reply-validator"
12
      />
13
      <NoticeMessage
14
        v-if="milesaway > faraway && message.type === 'Offer'"
132!
15
        variant="danger"
16
        class="mt-2 mb-1"
17
      >
18
        This item is {{ milesaway }} miles away. Before replying, are you sure
19
        you can collect from there?
20
      </NoticeMessage>
167!
21
      <NoticeMessage v-if="me?.deleted" variant="danger" class="mt-2 mb-1">
22
        You can't reply until you've decided whether to restore your account.
23
      </NoticeMessage>
×
24
      <div v-else>
25
        <VeeForm ref="form">
26
          <b-form-group
27
            class="flex-grow-1"
28
            label="Your reply:"
29
            :label-for="'replytomessage-' + message.id"
30
            :description="
31
              message.type === 'Offer'
32
                ? 'Explain why you\'d like it.  It\'s not always first come first served.  If appropriate, ask if it\'s working. Always be polite.'
33
                : 'Can you help?  If you have what they\'re looking for, let them know.'
34
            "
35
          >
36
            <div class="d-flex flex-wrap">
37
              <div v-if="message.deliverypossible" class="mb-2 mr-2">
247✔
38
                <b-badge
39
                  v-b-tooltip="
40
                    'They have said they may be able to deliver.  No guarantees - it needs to be convenient for them - but you can ask.'
41
                  "
42
                  variant="info"
43
                  ><v-icon icon="info-circle" /> Delivery may be
159✔
44
                  possible</b-badge
45
                >
46
              </div>
47
              <MessageDeadline :id="id" class="mb-2" />
48
            </div>
49
            <Field
50
              v-if="message.type == 'Offer'"
247!
51
              :id="'replytomessage-' + message.id"
52
              v-model="stateMachine.replyText.value"
268✔
53
              name="reply"
54
              :rules="validateReply"
55
              as="textarea"
56
              rows="3"
57
              max-rows="8"
58
              class="border border-success w-100"
59
              @input="stateMachine.startTyping"
60
            />
61
            <Field
62
              v-if="message.type == 'Wanted'"
247!
63
              :id="'replytomessage-' + message.id"
64
              v-model="stateMachine.replyText.value"
×
65
              name="reply"
66
              :rules="validateReply"
67
              as="textarea"
68
              rows="3"
69
              max-rows="8"
70
              class="flex-grow-1 w-100"
71
              @input="stateMachine.startTyping"
72
            />
73
          </b-form-group>
74
          <ErrorMessage name="reply" class="text-danger font-weight-bold" />
75
          <b-form-group
76
            v-if="message.type == 'Offer'"
230!
77
            class="mt-1"
78
            label="When could you collect?"
79
            :label-for="'replytomessage2-' + message.id"
80
            description="Suggest days and times you could collect if you're chosen.  Your plans might change but this speeds up making arrangements."
81
          >
82
            <Field
83
              :id="'replytomessage2-' + message.id"
84
              v-model="stateMachine.collectText.value"
263✔
85
              name="collect"
86
              :rules="validateCollect"
87
              class="border border-success w-100"
88
              as="textarea"
89
              rows="2"
90
              max-rows="2"
91
            />
92
          </b-form-group>
93
          <ErrorMessage name="collect" class="text-danger font-weight-bold" />
94
        </VeeForm>
95
        <p v-if="me && !alreadyAMember" class="text--small text-muted">
66✔
96
          You're not yet a member of this community; we'll join you. Change
97
          emails or leave communities from
98
          <em>Settings</em>.
99
        </p>
100
      </div>
101
      <div v-if="!me">
102
        <NewFreegler class="mt-2" />
103
      </div>
104
      <NoticeMessage
105
        v-if="stateMachine.error.value"
66!
106
        variant="danger"
107
        class="mt-2"
108
      >
109
        {{ stateMachine.error.value }}
110
        <b-button
111
          variant="link"
112
          size="sm"
113
          class="p-0 ml-2"
114
          @click="stateMachine.retry"
115
        >
116
          Try again
117
        </b-button>
87!
118
      </NoticeMessage>
119
    </div>
120
    <hr />
121
    <div class="d-flex justify-content-between">
122
      <div class="pr-2 w-50">
123
        <b-button variant="secondary" size="lg" block @click="close">
124
          Cancel
125
        </b-button>
240!
126
      </div>
127
      <div
128
        v-if="!fromme && !me?.deleted"
101✔
129
        class="pl-2 w-50 justify-content-end d-flex"
130
      >
131
        <SpinButton
132
          variant="primary"
133
          size="lg"
134
          done-icon=""
135
          icon-name="angle-double-right"
136
          :disabled="
137
            !stateMachine.canSend.value || stateMachine.isProcessing.value
138
          "
139
          iconlast
140
          @handle="handleSend"
141
        >
142
          Send <span class="d-none d-md-inline">your</span> reply
54✔
143
        </SpinButton>
144
      </div>
145
    </div>
146
    <b-modal
147
      v-if="stateMachine.showWelcomeModal.value"
66✔
148
      id="newUserModal"
149
      ref="newUserModal"
150
      scrollable
151
      ok-only
152
      ok-title="Close and Continue"
153
      @ok="handleNewUserModalOk"
154
    >
155
      <template #title>
156
        <h2>Welcome to Freegle!</h2>
157
      </template>
158
      <NewUserInfo :password="stateMachine.newUserPassword.value" />
159
    </b-modal>
160
    <span ref="breakpoint" class="d-inline d-sm-none" />
161
    <div class="d-none">
162
      <ChatButton ref="replyToPostChatButton" :userid="replyToUser" />
163
    </div>
164
  </div>
165
</template>
166
<script setup>
167
import { Form as VeeForm, Field, ErrorMessage } from 'vee-validate'
168
import {
169
  defineAsyncComponent,
170
  ref,
171
  computed,
172
  watch,
173
  nextTick,
174
  onMounted,
175
} from 'vue'
176
import { useRoute } from 'vue-router'
177
import { useMessageStore } from '~/stores/message'
178
import { milesAway } from '~/composables/useDistance'
179
import { useMe } from '~/composables/useMe'
180
import {
181
  useReplyStateMachine,
182
  ReplyState,
183
} from '~/composables/useReplyStateMachine'
184
import { action } from '~/composables/useClientLog'
185
import EmailValidator from '~/components/EmailValidator'
186
import NewUserInfo from '~/components/NewUserInfo'
187
import ChatButton from '~/components/ChatButton'
188
import SpinButton from '~/components/SpinButton.vue'
189
import NoticeMessage from '~/components/NoticeMessage'
190
import MessageDeadline from '~/components/MessageDeadline'
191
import { FAR_AWAY } from '~/constants'
192

193
const route = useRoute()
21✔
194

195
const NewFreegler = defineAsyncComponent(() =>
196
  import('~/components/NewFreegler')
197
)
198

199
const props = defineProps({
200
  id: {
201
    type: Number,
202
    required: true,
203
  },
204
  messageOverride: {
205
    type: Object,
206
    required: false,
207
    default: null,
208
  },
209
})
210

211
const emit = defineEmits(['close', 'sent'])
212

213
const faraway = FAR_AWAY
214

215
const messageStore = useMessageStore()
216
const { me, myid, myGroups } = useMe()
217

218
// Initialize state machine
219
const stateMachine = useReplyStateMachine(props.id)
220

221
// References
222
const form = ref(null)
223
const newUserModal = ref(null)
224
const replyToPostChatButton = ref(null)
225
const breakpoint = ref(null)
226
const emailValidatorRef = ref(null)
227

228
// Fetch the message data
229
await messageStore.fetch(props.id)
21✔
230

231
const message = computed(() => {
21✔
232
  return messageStore?.byId(props.id)
33!
233
})
234

235
const milesaway = computed(() => {
236
  return milesAway(me?.lat, me?.lng, message.value?.lat, message.value?.lng)
33!
237
})
238

239
const fromme = computed(() => {
240
  return message.value?.fromuser === myid.value
40!
241
})
242

243
const alreadyAMember = computed(() => {
244
  let found = false
38✔
245

246
  if (message.value?.groups) {
38✔
247
    for (const messageGroup of message.value.groups) {
35✔
248
      Object.keys(myGroups.value).forEach((key) => {
35✔
249
        const group = myGroups.value[key]
15✔
250

251
        if (messageGroup.groupid === group.id) {
15✔
252
          found = true
253
        }
254
      })
255
    }
256
  }
257

258
  return found
38✔
259
})
260

261
const replyToUser = computed(() => {
262
  return message.value?.fromuser
33!
263
})
264

265
// Watch for login state changes to resume authentication flow
266
watch(me, (newVal, oldVal) => {
21✔
267
  console.log('[MessageReplySection] Login state changed', {
19✔
268
    newVal: !!newVal,
269
    oldVal: !!oldVal,
270
  })
271
  if (
272
    !oldVal &&
38✔
273
    newVal &&
274
    stateMachine.state.value === ReplyState.AUTHENTICATING
275
  ) {
276
    console.log(
277
      '[MessageReplySection] User logged in during authentication - resuming'
278
    )
279
    stateMachine.onLoginSuccess()
280
  }
281
})
282

283
// Watch for chat button ref becoming available
284
watch(replyToPostChatButton, (newVal) => {
285
  if (newVal) {
21✔
286
    console.log('[MessageReplySection] Chat button ref now available')
287
    stateMachine.setRefs({ chatButton: newVal })
288
  }
289
})
290

291
// Watch for form ref
292
watch(form, (newVal) => {
293
  if (newVal) {
21✔
294
    console.log('[MessageReplySection] Form ref now available')
295
    stateMachine.setRefs({ form: newVal })
296
  }
297
})
298

299
// Determine reply source from route for analytics.
300
function getReplySource() {
21✔
301
  const path = route.path
21✔
302
  const query = route.query || {}
21!
303

304
  // Check for email digest/newsletter links.
305
  if (query.src === 'digest' || query.utm_source === 'digest') {
21!
306
    return 'email_digest'
307
  }
308
  if (query.src === 'newsletter' || query.utm_source === 'newsletter') {
21!
309
    return 'email_newsletter'
310
  }
311
  if (query.src || query.utm_source) {
21!
312
    return `email_${query.src || query.utm_source}`
×
313
  }
314

315
  // Determine from route path.
316
  if (path.startsWith('/browse')) {
21!
317
    return 'browse_page'
318
  }
319
  if (path.startsWith('/explore')) {
21✔
320
    return 'explore_page'
321
  }
322
  if (path.match(/^\/message\/\d+/)) {
15!
323
    return 'message_page'
324
  }
325
  if (path.startsWith('/find')) {
×
326
    return 'find_page'
327
  }
328

329
  return 'unknown'
330
}
331

332
// Set refs on mount
333
onMounted(() => {
21✔
334
  console.log('[MessageReplySection] Component mounted, setting refs')
21✔
335
  stateMachine.setRefs({
336
    form: form.value,
337
    chatButton: replyToPostChatButton.value,
338
    emailValidator: emailValidatorRef.value,
339
  })
340

341
  // Set and log the reply source for analytics.
342
  const replySource = getReplySource()
21✔
343
  stateMachine.setReplySource(replySource)
21✔
344
  action('reply_section_viewed', {
345
    message_id: props.id,
346
    reply_source: replySource,
347
    message_type: message.value?.type,
21!
348
    is_logged_in: !!me.value,
349
    route_path: route.path,
350
    route_query: JSON.stringify(route.query || {}),
21!
351
  })
352
})
353

354
// Watch for state machine completion
355
watch(
356
  () => stateMachine.isComplete.value,
357
  (isComplete) => {
358
    if (isComplete) {
7✔
359
      console.log('[MessageReplySection] Reply flow completed')
360
      sent()
361
    }
362
  }
363
)
364

365
// Watch for welcome modal state to show it
366
watch(
367
  () => stateMachine.showWelcomeModal.value,
368
  async (showModal) => {
2✔
369
    if (showModal) {
2✔
370
      console.log('[MessageReplySection] Showing welcome modal')
371
      await nextTick()
4✔
372
      newUserModal.value?.show()
373
    }
374
  }
375
)
376

377
function validateCollect(value) {
69✔
378
  if (value && value.trim()) {
69✔
379
    return true
380
  }
381
  return 'Please suggest some days and times when you could collect.'
382
}
383

384
function validateReply(value) {
78✔
385
  if (!value?.trim()) {
78!
386
    return 'Please fill out your reply.'
387
  }
388

389
  if (
390
    message.value?.type === 'Offer' &&
391
    value &&
392
    value.length <= 35 &&
393
    value.toLowerCase().includes('still available')
394
  ) {
395
    return (
396
      "You don't need to ask if things are still available. Just write whatever you " +
397
      "would have said next - explain why you'd like it and when you could collect."
398
    )
399
  }
400

401
  return true
402
}
403

404
async function handleSend(callback) {
12✔
405
  console.log('[MessageReplySection] handleSend called')
12✔
406
  // Ensure refs are set before submitting
407
  stateMachine.setRefs({
408
    form: form.value,
409
    chatButton: replyToPostChatButton.value,
410
    emailValidator: emailValidatorRef.value,
411
  })
412
  await stateMachine.submit(callback)
413
}
414

415
function handleNewUserModalOk() {
×
NEW
416
  console.log('[MessageReplySection] New user modal closed')
×
417
  stateMachine.closeWelcomeModal()
418
}
419

420
function close() {
×
421
  emit('close')
×
422
}
423

424
function sent() {
7✔
425
  emit('sent')
7✔
426
}
427
</script>
428
<style scoped lang="scss">
429
.grey {
430
  background-color: $color-gray--lighter;
431
}
432

433
:deep(.phone) {
434
  border: 2px solid $color-gray--normal !important;
435
}
436

437
.nobot {
438
  margin-bottom: 0 !important;
439
}
440
</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