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

Freegle / iznik-nuxt3 / 87afe2cc-83c0-41d0-b68b-b0c6a3d6fadb

19 Dec 2025 11:02PM UTC coverage: 42.889% (+0.02%) from 42.868%
87afe2cc-83c0-41d0-b68b-b0c6a3d6fadb

push

circleci

actions-user
Auto-merge production to app-ci-fd (daily scheduled)

Automated merge from production branch after successful tests.

🤖 Automated by GitHub Actions

2845 of 7272 branches covered (39.12%)

Branch coverage included in aggregate %.

18 of 45 new or added lines in 6 files covered. (40.0%)

203 existing lines in 8 files now uncovered.

3391 of 7268 relevant lines covered (46.66%)

13.93 hits per line

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

54.39
/components/MyMessage.vue
1
<template>
2
  <div v-observe-visibility="visibilityChanged" class="my-message-mobile">
27✔
3
    <div v-if="visible && message?.id">
27✔
4
      <div
5
        v-if="showOld || !message.outcomes?.length"
57!
6
        class="message-card"
7
        :data-message-id="message.id"
8
      >
9
        <!-- Rejected notice -->
10
        <notice-message v-if="rejected" class="mb-2" variant="warning">
11
          <v-icon icon="exclamation-triangle" /> This post has been returned to
×
12
          you. It is not public yet.
13
        </notice-message>
14

15
        <!-- Main content area with photo -->
16
        <div class="photo-section" @click="goToPost">
17
          <!-- Photo area -->
18
          <div class="photo-area">
19
            <!-- Status overlays -->
20
            <b-img
21
              v-if="taken || received"
57!
22
              lazy
23
              src="/freegled.jpg"
24
              class="status-overlay"
25
              alt="Completed"
26
            />
27
            <!-- Promised banner at top -->
28
            <div
29
              v-else-if="message.promised && !message.outcomes?.length"
19!
30
              class="promised-banner"
31
            >
32
              <div class="promised-banner-text">
33
                <v-icon icon="handshake" class="me-2" />
×
34
                Promised to&nbsp;<strong>{{ promisedToName }}</strong>
35
              </div>
36
              <button class="unpromise-btn" @click.stop="unpromise">
37
                Unpromise
38
              </button>
39
            </div>
40

41
            <!-- Photo -->
42
            <div v-if="hasPhoto" class="photo-container">
19!
43
              <OurUploadedImage
44
                v-if="message.attachments[0]?.ouruid"
45
                :src="message.attachments[0].ouruid"
46
                :modifiers="message.attachments[0].externalmods"
47
                alt="Item Photo"
48
                class="photo-image"
49
                :width="400"
50
                :height="200"
×
51
              />
52
              <NuxtPicture
53
                v-else-if="message.attachments[0]?.externaluid"
54
                format="webp"
55
                provider="uploadcare"
56
                :src="message.attachments[0].externaluid"
57
                :modifiers="message.attachments[0].externalmods"
58
                alt="Item Photo"
59
                class="photo-image"
60
                :width="400"
61
                :height="200"
×
62
              />
63
              <ProxyImage
64
                v-else-if="message.attachments[0]?.path"
65
                class-name="photo-image"
66
                alt="Item picture"
67
                :src="message.attachments[0].path"
68
                :width="400"
69
                :height="200"
70
                fit="cover"
71
              />
72
            </div>
73

74
            <!-- No photo placeholder -->
75
            <div v-else class="no-photo-placeholder" :class="placeholderClass">
76
              <div class="placeholder-pattern"></div>
77
              <div class="icon-circle">
78
                <v-icon :icon="categoryIcon" class="placeholder-icon" />
79
              </div>
80
            </div>
81

82
            <!-- Title overlay -->
83
            <div class="title-overlay">
84
              <div class="title-row">
85
                <div class="title-content">
86
                  <MessageTag
87
                    :id="message.id"
88
                    :inline="true"
89
                    class="title-tag ps-1 pe-1"
90
                  />
91
                  <span class="title-subject">{{ strippedSubject }}</span>
92
                </div>
93
                <div class="photo-actions">
94
                  <button class="photo-action-btn" @click.stop="share">
95
                    <v-icon icon="share-alt" />
96
                  </button>
97
                </div>
98
              </div>
99
              <div v-if="message.area" class="info-row">
100
                <span class="location">
101
                  <v-icon icon="map-marker-alt" class="me-1" />{{
102
                    message.area
103
                  }}
104
                </span>
105
              </div>
106
              <div class="group-row">
107
                <nuxt-link
108
                  v-if="messageGroup"
109
                  :to="'/explore/' + messageGroup.nameshort"
110
                  class="group-link"
111
                  @click.stop
21✔
112
                >
113
                  {{ messageGroup.namedisplay }}
114
                </nuxt-link>
115
                <span
19✔
116
                  v-if="messageGroup && timeAgoExpanded"
19!
117
                  class="group-time-separator"
19!
118
                  >·</span
119
                >
120
                <span v-if="timeAgoExpanded" class="group-time"
121
                  >{{ timeAgoExpanded }} ago</span
122
                >
123
              </div>
124
            </div>
125
          </div>
126
        </div>
64✔
127

128
        <!-- Action buttons - compact row -->
18✔
129
        <div v-if="!rejected" class="action-buttons">
130
          <div class="action-buttons-left">
131
            <button
18✔
132
              v-if="message.type === 'Offer' && !taken && !withdrawn"
133
              class="action-btn action-btn--primary"
134
              @click="outcome('Taken', $event)"
50✔
135
            >
136
              <v-icon icon="check" />
9✔
137
              <span>TAKEN</span>
138
            </button>
139
            <button
9✔
140
              v-if="message.type === 'Wanted' && !received && !withdrawn"
141
              class="action-btn action-btn--primary"
142
              @click="outcome('Received', $event)"
19✔
143
            >
144
              <v-icon icon="check" />
145
              <span>RECEIVED</span>
146
            </button>
18✔
147
            <button
148
              v-if="
149
                message.type === 'Offer' && !taken && !withdrawn && !isPromised
18✔
150
              "
76!
151
              class="action-btn action-btn--secondary"
152
              @click="openPromiseModal"
153
            >
154
              <v-icon icon="handshake" />
27✔
155
              <span>Promise</span>
156
            </button>
157
            <button
27✔
158
              v-if="!taken && !received && !withdrawn"
159
              class="action-btn action-btn--light"
160
              @click="outcome('Withdrawn', $event)"
70✔
161
            >
162
              <v-icon icon="trash-alt" />
163
              <span>Withdraw</span>
164
            </button>
165
            <button
21✔
166
              v-if="message.canrepost && message.location && message.item"
167
              class="action-btn action-btn--light"
168
              @click="repost"
169
            >
170
              <v-icon icon="sync" />
171
              <span>Repost</span>
172
            </button>
173
          </div>
174
          <div class="action-buttons-right">
175
            <button
27✔
176
              v-if="!message.outcomes?.length"
177
              class="action-btn action-btn--light"
178
              @click="edit"
179
            >
180
              <v-icon icon="pen" />
181
              <span>Edit</span>
182
            </button>
183
          </div>
×
184
        </div>
185

186
        <!-- Rejected actions -->
187
        <div v-else class="action-buttons">
188
          <button
×
189
            v-if="message.location && message.item"
×
190
            class="action-btn action-btn--warning"
191
            @click="repost"
192
          >
193
            <v-icon icon="pen" />
×
194
            <span>Edit & Resend</span>
195
          </button>
196
          <button
×
197
            v-if="!withdrawn"
38!
198
            class="action-btn action-btn--light"
199
            @click="outcome('Withdrawn', $event)"
200
          >
201
            <v-icon icon="trash-alt" />
202
            <span>Withdraw</span>
203
          </button>
204
        </div>
205

8✔
206
        <!-- Replies section -->
207
        <div v-if="replies?.length > 0" class="replies-section">
8✔
208
          <div class="replies-header" @click="toggleExpanded">
8✔
209
            <div class="replies-avatars">
8✔
210
              <ProfileImage
8✔
211
                v-for="(reply, index) in repliesPreview"
212
                :key="'avatar-' + reply.userid"
213
                :image="getUserProfile(reply.userid)?.paththumb"
214
                :externaluid="getUserProfile(reply.userid)?.externaluid"
215
                :ouruid="getUserProfile(reply.userid)?.ouruid"
216
                :externalmods="getUserProfile(reply.userid)?.externalmods"
217
                :name="getUserName(reply.userid)"
8!
218
                class="reply-avatar"
219
                :style="{ zIndex: index + 1 }"
220
                is-thumbnail
221
                size="sm"
222
              />
223
              <div v-if="replies.length > 3" class="more-count">
224
                +{{ replies.length - 3 }}
8!
225
              </div>
226
            </div>
227
            <div class="replies-text">
8!
228
              <span class="replies-count"
229
                >{{ replies.length }}
230
                {{ replies.length === 1 ? 'reply' : 'replies' }}</span
231
              >
232
              <v-icon
233
                :icon="expanded ? 'caret-up' : 'caret-down'"
234
                class="expand-icon"
235
              />
236
            </div>
2✔
237
          </div>
238

239
          <Transition name="replies-slide">
240
            <div v-if="expanded" class="replies-list">
241
              <MyMessageReply
242
                v-for="reply in replies"
243
                :key="'reply-' + reply.id"
244
                :reply="reply"
245
                :chats="chats"
246
                :message="message"
247
                :taken="taken"
248
                :received="received"
249
                :withdrawn="withdrawn"
250
                :closest="reply.userid === closestUser"
251
                :best="reply.userid === bestRatedUser"
11!
252
                :quickest="reply.userid === quickestUser"
253
              />
254
            </div>
255
          </Transition>
256
        </div>
257
        <div v-else-if="willAutoRepost" class="no-replies">
258
          <p class="text-muted small">
259
            No replies yet. Will auto-repost {{ canrepostatago }}.
260
          </p>
19✔
261
        </div>
262
      </div>
263

6✔
264
      <!-- Modals -->
6✔
265
      <OutcomeModal
266
        v-if="showOutcomeModal"
267
        :id="id"
19!
268
        :type="outcomeType"
269
        @outcome="bump++"
×
270
        @hidden="showOutcomeModal = false"
271
      />
19!
272
      <MessageShareModal
273
        v-if="showShareModal"
19!
274
        :id="message.id"
275
        @hidden="showShareModal = false"
276
      />
277
      <MessageEditModal v-if="showEditModal" :id="id" @hidden="hidden" />
×
278
      <PromiseModal
279
        v-if="showPromiseModal"
280
        :messages="[message]"
38!
281
        :selected-message="message.id"
282
        :users="replyusers"
283
        @hidden="showPromiseModal = false"
284
      />
×
285
      <RenegeModal
×
286
        v-if="showRenegeModal && promisedTo.length > 0"
287
        :messages="[message.id]"
288
        :selected-message="message.id"
289
        :users="promisedToUsers"
290
        :selected-user="promisedTo[0]?.id"
291
        @hidden="showRenegeModal = false"
292
      />
293
    </div>
294
  </div>
295
</template>
296

297
<script setup>
298
import dayjs from 'dayjs'
299
import { useComposeStore } from '~/stores/compose'
300
import { useMessageStore } from '~/stores/message'
301
import { useChatStore } from '~/stores/chat'
302
import { useUserStore } from '~/stores/user'
303
import { useTrystStore } from '~/stores/tryst'
304
import { useLocationStore } from '~/stores/location'
305
import { useGroupStore } from '~/stores/group'
306
import { timeago } from '~/composables/useTimeFormat'
307
import { milesAway } from '~/composables/useDistance'
308
import { onMounted, ref, computed, watch, useRouter, toRef } from '#imports'
309
import { useMe } from '~/composables/useMe'
8✔
310
import { useMessageDisplay } from '~/composables/useMessageDisplay'
311
import ProfileImage from '~/components/ProfileImage'
312
import MessageTag from '~/components/MessageTag'
8✔
313
import OurUploadedImage from '~/components/OurUploadedImage'
314

315
const MyMessageReply = defineAsyncComponent(() =>
8✔
316
  import('./MyMessageReply.vue')
317
)
318
const MessageShareModal = defineAsyncComponent(() =>
8✔
319
  import('./MessageShareModal')
320
)
321
const NoticeMessage = defineAsyncComponent(() =>
8✔
322
  import('~/components/NoticeMessage')
8✔
323
)
324
const PromiseModal = defineAsyncComponent(() =>
325
  import('~/components/PromiseModal')
8✔
326
)
327
const OutcomeModal = defineAsyncComponent(() => import('./OutcomeModal'))
8✔
328
const MessageEditModal = defineAsyncComponent(() =>
329
  import('./MessageEditModal')
330
)
331
const RenegeModal = defineAsyncComponent(() => import('./RenegeModal'))
332

333
const props = defineProps({
334
  id: {
335
    type: Number,
336
    required: true,
337
  },
338
  showOld: {
339
    type: Boolean,
340
    required: true,
341
  },
342
  expand: {
343
    type: Boolean,
8✔
344
    required: false,
8✔
345
    default: false,
8✔
346
  },
8✔
347
})
8✔
348

8✔
349
const messageStore = useMessageStore()
8✔
350
const chatStore = useChatStore()
8✔
351
const userStore = useUserStore()
8✔
352
const trystStore = useTrystStore()
353
const composeStore = useComposeStore()
354
const locationStore = useLocationStore()
8✔
355
const groupStore = useGroupStore()
8✔
356
const router = useRouter()
357
const { me } = useMe()
358

359
// Use shared display composable
360
const idRef = toRef(props, 'id')
361
const {
362
  message,
363
  strippedSubject,
364
  gotAttachments: hasPhoto,
365
  timeAgoExpanded,
8✔
366
  placeholderClass,
8✔
367
  categoryIcon,
8✔
368
} = useMessageDisplay(idRef)
8✔
369

8✔
370
// Data properties
8✔
371
const visible = ref(false)
8✔
372
const expanded = ref(false)
8✔
373
const showOutcomeModal = ref(false)
8✔
374
const outcomeType = ref(null)
375
const showEditModal = ref(false)
376
const showShareModal = ref(false)
8✔
377
const showPromiseModal = ref(false)
24!
UNCOV
378
const showRenegeModal = ref(false)
×
379
const bump = ref(0)
380

24✔
381
// Computed
382
const hasOutcome = (val) => {
383
  if (message.value?.outcomes?.length) {
8✔
384
    return message.value.outcomes.some((o) => o.outcome === val)
8✔
385
  }
8✔
386
  return false
387
}
8✔
388

8!
389
const taken = computed(() => hasOutcome('Taken'))
5✔
390
const received = computed(() => hasOutcome('Received'))
391
const withdrawn = computed(() => hasOutcome('Withdrawn'))
3✔
392

393
const rejected = computed(() => {
394
  if (message.value?.groups) {
8✔
395
    return message.value.groups.some((g) => g.collection === 'Rejected')
13✔
396
  }
2✔
397
  return false
2!
398
})
399

2✔
400
const replies = computed(() => {
UNCOV
401
  if (message.value?.replies) {
×
UNCOV
402
    const promisedUserIds = new Set(
×
UNCOV
403
      (message.value.promises || []).map((p) => p.userid)
×
UNCOV
404
    )
×
405
    return [...message.value.replies].sort((a, b) => {
UNCOV
406
      // Promised users come first
×
407
      const aPromised = promisedUserIds.has(a.userid)
408
      const bPromised = promisedUserIds.has(b.userid)
409
      if (aPromised && !bPromised) return -1
11✔
410
      if (!aPromised && bPromised) return 1
411
      // Then sort by date (most recent first)
412
      return new Date(b.date).getTime() - new Date(a.date).getTime()
8✔
413
    })
2✔
414
  }
415
  return []
416
})
8✔
417

13✔
418
const repliesPreview = computed(() => {
13✔
419
  return replies.value.slice(0, 3)
420
})
13✔
421

2✔
422
const replyuserids = computed(() => {
2!
423
  const ret = []
2✔
424
  const seen = {}
2✔
425

426
  // First add users who sent "Interested" replies to this post
427
  if (message.value?.replies) {
428
    for (const reply of message.value.replies) {
429
      if (!seen[reply.userid]) {
13✔
430
        ret.push(reply.userid)
5✔
UNCOV
431
        seen[reply.userid] = true
×
UNCOV
432
      }
×
UNCOV
433
    }
×
434
  }
435

436
  // Then add users who are already promised
437
  if (message.value?.promises) {
438
    for (const promise of message.value.promises) {
13✔
439
      if (!seen[promise.userid]) {
440
        ret.push(promise.userid)
441
        seen[promise.userid] = true
8✔
442
      }
4!
443
    }
444
  }
445

8✔
446
  // Finally add all users from all chats (so we can promise to anyone we've chatted with)
4✔
447
  const chatsList = chatStore?.list || []
4✔
448
  for (const chat of chatsList) {
449
    if (chat.otheruid && !seen[chat.otheruid]) {
4!
NEW
450
      ret.push(chat.otheruid)
×
NEW
451
      seen[chat.otheruid] = true
×
NEW
452
    }
×
NEW
453
  }
×
NEW
454

×
UNCOV
455
  return ret
×
456
})
457

458
const replyusers = computed(() => {
459
  return replyuserids.value.map((uid) => userStore?.byId(uid)).filter((u) => u)
460
})
461

4✔
462
const closestUser = computed(() => {
463
  let ret = null
464
  let dist = null
8✔
465

4✔
466
  if (replyusers.value?.length > 1 && me.value) {
4✔
467
    replyusers.value.forEach((u) => {
468
      if (u) {
4!
469
        const miles = milesAway(u.lat, u.lng, me.value.lat, me.value.lng)
×
470
        if (dist === null || miles < dist) {
×
471
          dist = miles
×
472
          ret = u.id
UNCOV
473
        }
×
474
      }
×
475
    })
476
  }
477

UNCOV
478
  return ret
×
UNCOV
479
})
×
480

481
const bestRatedUser = computed(() => {
482
  let ret = null
483
  let rating = null
484

485
  if (replyusers.value?.length > 1) {
4✔
486
    replyusers.value.forEach((u) => {
487
      if (u && u.info?.ratings?.Up + u.info?.ratings?.Down > 2) {
488
        const thisrating =
8✔
489
          u.info.ratings.Up / (u.info.ratings.Up + u.info.ratings.Down)
4✔
490
        if (
4✔
491
          u.info.ratings.Up > u.info.ratings.Down &&
492
          u.info.ratings.Up > 2 &&
4!
UNCOV
493
          (rating === null || thisrating > rating)
×
UNCOV
494
        ) {
×
495
          rating = thisrating
×
496
          ret = u.id
497
        }
498
      }
UNCOV
499
    })
×
UNCOV
500
  }
×
501

502
  return ret
503
})
504

505
const quickestUser = computed(() => {
4✔
506
  let ret = null
507
  let replytime = null
508

8✔
509
  if (replyusers.value?.length > 1) {
2!
510
    replyusers.value.forEach((u) => {
2!
511
      if (
512
        u &&
513
        u.info?.replytime &&
8✔
UNCOV
514
        (replytime === null || u.info.replytime < replytime)
×
UNCOV
515
      ) {
×
516
        replytime = u.info.replytime
×
517
        ret = u.id
×
UNCOV
518
      }
×
UNCOV
519
    })
×
UNCOV
520
  }
×
521

522
  return ret
UNCOV
523
})
×
524

525
const chats = computed(() => {
526
  const chatsList = chatStore?.list || []
UNCOV
527
  return chatsList.filter((c) => message.value?.refchatids?.includes(c.id))
×
528
})
529

530
const promisedTo = computed(() => {
8✔
531
  const ret = []
×
532
  if (message.value?.promises?.length) {
×
533
    message.value.promises.forEach((p) => {
534
      const user = userStore?.byId(p.userid)
×
535
      if (user) {
536
        const tryst = trystStore?.getByUser(p.userid)
537
        const date = tryst
8✔
UNCOV
538
          ? dayjs(tryst.arrangedfor).format('ddd Do HH:mm')
×
539
          : null
540
        ret.push({ id: p.userid, name: user.displayname, trystdate: date })
541
      }
8✔
542
    })
5!
543
  }
544
  return ret
545
})
8✔
546

6!
547
const promisedToName = computed(() => {
6✔
548
  if (promisedTo.value.length > 0) {
549
    return promisedTo.value[0].name
×
550
  }
551
  return ''
552
})
8✔
UNCOV
553

×
554
const promisedToUsers = computed(() => {
555
  return promisedTo.value.map((p) => userStore?.byId(p.id)).filter((u) => u)
556
})
8✔
557

14!
558
const isPromised = computed(() => {
11✔
559
  return message.value?.promised && !message.value?.outcomes?.length
11!
560
})
561

3✔
562
const willAutoRepost = computed(() => {
563
  if (taken.value || received.value || !message.value?.canrepostat) {
564
    return false
565
  }
32✔
566
  return dayjs(message.value.canrepostat).isAfter(dayjs())
32!
567
})
568

569
const canrepostatago = computed(() => {
8✔
570
  return message.value?.canrepostat ? timeago(message.value.canrepostat) : null
8!
571
})
572

UNCOV
573
const messageGroup = computed(() => {
×
UNCOV
574
  if (message.value?.groups?.length) {
×
575
    const groupId = message.value.groups[0].groupid
576
    return groupStore?.get(groupId)
UNCOV
577
  }
×
UNCOV
578
  return null
×
579
})
580

581
// Methods
8✔
582
function getUserProfile(userid) {
18✔
583
  return userStore?.byId(userid)?.profile
9✔
584
}
9✔
585

586
function getUserName(userid) {
587
  return userStore?.byId(userid)?.displayname
9!
588
}
6✔
589

590
function toggleExpanded() {
591
  expanded.value = !expanded.value
592
}
593

8✔
594
function goToPost() {
3!
595
  router.push('/mypost/' + props.id)
3✔
596
}
3✔
597

598
const visibilityChanged = async (isVisible) => {
3✔
599
  if (isVisible) {
3✔
600
    const msg = await messageStore.fetch(props.id)
601
    visible.value = isVisible
602

8✔
UNCOV
603
    // Fetch group info for display
×
UNCOV
604
    if (msg?.groups?.length) {
×
UNCOV
605
      groupStore.fetch(msg.groups[0].groupid)
×
606
    }
UNCOV
607
  }
×
608
}
609

610
const outcome = (type, e) => {
8✔
UNCOV
611
  if (e) {
×
UNCOV
612
    e.preventDefault()
×
UNCOV
613
    e.stopPropagation()
×
614
  }
UNCOV
615
  showOutcomeModal.value = true
×
616
  outcomeType.value = type
617
}
618

8✔
UNCOV
619
const share = (e) => {
×
620
  if (e) {
×
621
    e.preventDefault()
×
622
    e.stopPropagation()
UNCOV
623
  }
×
624
  showShareModal.value = true
×
625
}
626

627
const openPromiseModal = (e) => {
8✔
NEW
628
  if (e) {
×
NEW
629
    e.preventDefault()
×
NEW
630
    e.stopPropagation()
×
631
  }
632
  // Fetch chats so we can show users with active chats
NEW
633
  chatStore.fetchChats()
×
634
  showPromiseModal.value = true
NEW
635
}
×
636

637
const unpromise = (e) => {
638
  if (e) {
639
    e.preventDefault()
640
    e.stopPropagation()
×
641
  }
×
642
  showRenegeModal.value = true
643
}
644

645
const edit = async (e) => {
646
  if (e) {
647
    e.preventDefault()
648
    e.stopPropagation()
649
  }
650
  await messageStore.fetch(props.id, true)
×
651
  showEditModal.value = true
×
UNCOV
652
}
×
653

654
const repost = async (e) => {
655
  if (e) {
×
656
    e.preventDefault()
×
657
    e.stopPropagation()
658
  }
659

8✔
660
  await composeStore.clearMessages()
×
UNCOV
661

×
662
  await composeStore.setMessage(
663
    0,
664
    {
665
      id: message.value.id,
8✔
666
      savedBy: message.value.fromuser,
667
      item: message.value.item?.name.trim(),
668
      description: message.value.textbody?.trim() || null,
13✔
669
      availablenow: message.value.availablenow,
5✔
670
      type: message.value.type,
671
      repostof: props.id,
13✔
672
      deadline: null,
2✔
673
    },
674
    me
675
  )
676

677
  if (message.value.location) {
678
    const locs = await locationStore.typeahead(message.value.location.name)
8✔
679
    composeStore.postcode = locs[0]
680
  }
681

13!
682
  await composeStore.setAttachmentsForMessage(0, message.value.attachments)
2✔
683
  router.push(message.value.type === 'Offer' ? '/give' : '/find')
684
}
685

686
const hidden = () => {
687
  showEditModal.value = false
688
  messageStore.fetch(props.id)
8✔
689
}
5✔
690

691
// Watchers
692
watch(
8✔
693
  message,
8✔
694
  (newVal) => {
695
    if (newVal?.promises) {
696
      newVal.promises.forEach((p) => userStore.fetch(p.userid))
697
    }
698
    if (newVal?.replycount === 1) {
699
      expanded.value = true
700
    }
701
  },
702
  { immediate: true }
703
)
704

705
watch(
706
  replies,
707
  (newVal) => {
708
    if (newVal?.length === 1) {
709
      expanded.value = true
710
    }
711
  },
712
  { immediate: true }
713
)
714

715
watch(replyuserids, (newVal) => {
716
  newVal.forEach((uid) => userStore.fetch(uid))
717
})
718

719
onMounted(() => {
720
  expanded.value = props.expand
721
})
722
</script>
723

724
<style scoped lang="scss">
725
@import 'bootstrap/scss/functions';
726
@import 'bootstrap/scss/variables';
727
@import 'assets/css/_color-vars.scss';
728

729
.my-message-mobile {
730
  margin-bottom: 12px;
731
}
732

733
.message-card {
734
  background: white;
735
  border-radius: 0;
736
  overflow: hidden;
737
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
738
}
739

740
.photo-section {
741
  cursor: pointer;
742
}
743

744
.photo-area {
745
  position: relative;
746
  width: 100%;
747
  height: 0;
748
  padding-bottom: 50%;
749
  background: $color-gray--light;
750
  overflow: hidden;
751
}
752

753
.photo-container {
754
  position: absolute;
755
  top: 0;
756
  left: 0;
757
  width: 100%;
758
  height: 100%;
759
}
760

761
:deep(.photo-image),
762
:deep(picture),
763
:deep(picture img) {
764
  width: 100%;
765
  height: 100%;
766
  object-fit: cover;
767
  display: block;
768
  position: absolute;
769
  top: 0;
770
  left: 0;
771
}
772

773
.no-photo-placeholder {
774
  position: absolute;
775
  top: 0;
776
  left: 0;
777
  right: 0;
778
  bottom: 0;
779
  display: flex;
780
  align-items: center;
781
  justify-content: center;
782

783
  &.offer-gradient {
784
    background: radial-gradient(
785
        ellipse at 30% 20%,
786
        rgba(129, 199, 132, 0.9) 0%,
787
        transparent 50%
788
      ),
789
      radial-gradient(
790
        ellipse at 70% 80%,
791
        rgba(56, 142, 60, 0.8) 0%,
792
        transparent 50%
793
      ),
794
      linear-gradient(160deg, #66bb6a 0%, #43a047 50%, #2e7d32 100%);
795

796
    .placeholder-pattern {
797
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='70' height='70' viewBox='0 0 70 70'%3E%3Ctext x='35' y='52' font-family='Arial,sans-serif' font-size='50' font-weight='bold' fill='white' fill-opacity='0.12' text-anchor='middle'%3E?%3C/text%3E%3C/svg%3E");
798
      background-size: 70px 70px;
799
    }
800
  }
801

802
  &.wanted-gradient {
803
    background: radial-gradient(
804
        ellipse at 25% 25%,
805
        rgba(144, 202, 249, 0.9) 0%,
806
        transparent 45%
807
      ),
808
      radial-gradient(
809
        ellipse at 75% 75%,
810
        rgba(66, 165, 245, 0.7) 0%,
811
        transparent 45%
812
      ),
813
      linear-gradient(160deg, #64b5f6 0%, #42a5f5 50%, #1e88e5 100%);
814

815
    .placeholder-pattern {
816
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='70' height='70' viewBox='0 0 70 70'%3E%3Ctext x='35' y='52' font-family='Arial,sans-serif' font-size='50' font-weight='bold' fill='white' fill-opacity='0.12' text-anchor='middle'%3E?%3C/text%3E%3C/svg%3E");
817
      background-size: 70px 70px;
818
    }
819
  }
820
}
821

822
.placeholder-pattern {
823
  position: absolute;
824
  top: 0;
825
  left: 0;
826
  right: 0;
827
  bottom: 0;
828
}
829

830
.icon-circle {
831
  width: 50px;
832
  height: 50px;
833
  border-radius: 50%;
834
  background: rgba(255, 255, 255, 0.2);
835
  display: flex;
836
  align-items: center;
837
  justify-content: center;
838
  backdrop-filter: blur(4px);
839
}
840

841
.placeholder-icon {
842
  font-size: 1.5rem;
843
  color: rgba(255, 255, 255, 0.9);
844
}
845

846
.status-overlay {
847
  position: absolute;
848
  z-index: 10;
849
  transform: rotate(15deg);
850
  top: 50%;
851
  left: 50%;
852
  width: 40%;
853
  max-width: 80px;
854
  margin-left: -20%;
855
  margin-top: -10%;
856
  pointer-events: none;
857
}
858

859
.promised-banner {
860
  position: absolute;
861
  z-index: 10;
862
  top: 0;
863
  left: 0;
864
  right: 0;
865
  padding: 0.5rem 0.75rem;
866
  background: linear-gradient(
867
    to bottom,
868
    rgba(0, 123, 255, 0.95) 0%,
869
    rgba(0, 123, 255, 0.85) 100%
870
  );
871
  color: white;
872
  font-size: 0.85rem;
873
  font-weight: 500;
874
  display: flex;
875
  align-items: center;
876
  justify-content: space-between;
877
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
878
}
879

880
.promised-banner-text {
881
  display: flex;
882
  align-items: center;
883
}
884

885
.unpromise-btn {
886
  background: $color-orange--dark;
887
  border: none;
888
  color: white;
889
  padding: 2px 10px;
890
  font-size: 0.75rem;
891
  cursor: pointer;
892
  transition: background 0.2s;
893

894
  &:hover {
895
    background: darken($color-orange--dark, 10%);
896
  }
897
}
898

899
.photo-actions {
900
  display: flex;
901
  gap: 6px;
902
  flex-shrink: 0;
903
  margin-left: 8px;
904
}
905

906
.photo-action-btn {
907
  width: 28px;
908
  height: 28px;
909
  border-radius: 50%;
910
  border: none;
911
  background: rgba(255, 255, 255, 0.25);
912
  color: white;
913
  display: flex;
914
  align-items: center;
915
  justify-content: center;
916
  cursor: pointer;
917
  transition: all 0.2s;
918

919
  &:hover {
920
    background: rgba(255, 255, 255, 0.4);
921
  }
922

923
  svg {
924
    font-size: 0.75rem;
925
  }
926
}
927

928
.title-overlay {
929
  position: absolute;
930
  bottom: 0;
931
  left: 0;
932
  right: 0;
933
  padding: 2rem 0.75rem 0.5rem;
934
  background: linear-gradient(
935
    to top,
936
    rgba(0, 0, 0, 0.85) 0%,
937
    rgba(0, 0, 0, 0.6) 70%,
938
    transparent 100%
939
  );
940
  color: white;
941
}
942

943
.title-row {
944
  display: flex;
945
  align-items: center;
946
  justify-content: space-between;
947
  gap: 8px;
948
  margin-bottom: 0.15rem;
949
}
950

951
.title-content {
952
  display: flex;
953
  align-items: center;
954
  gap: 0.25rem;
955
  flex: 1;
956
  min-width: 0;
957
}
958

959
.title-tag {
960
  flex-shrink: 0;
961
  font-size: 0.6rem;
962
}
963

964
:deep(.title-tag .tagbadge) {
965
  font-size: 0.6rem;
966
}
967

968
.title-subject {
969
  font-size: 0.9rem;
970
  font-weight: 600;
971
  line-height: 1.2;
972
}
973

974
.info-row {
975
  display: flex;
976
  justify-content: space-between;
977
  font-size: 0.7rem;
978
  opacity: 0.9;
979
}
980

981
.group-row {
982
  font-size: 0.65rem;
983
  opacity: 0.85;
984
  margin-top: 2px;
985
  display: flex;
986
  align-items: center;
987
  gap: 4px;
988
}
989

990
.group-link {
991
  color: white;
992
  text-decoration: none;
993

994
  &:hover {
995
    text-decoration: underline;
996
  }
997
}
998

999
.group-time-separator {
1000
  opacity: 0.6;
1001
}
1002

1003
.group-time {
1004
  opacity: 0.7;
1005
}
1006

1007
.action-buttons {
1008
  display: flex;
1009
  justify-content: space-between;
1010
  align-items: center;
1011
  gap: 6px;
1012
  padding: 10px 12px;
1013
  border-bottom: 1px solid $color-gray--lighter;
1014
}
1015

1016
.action-buttons-left {
1017
  display: flex;
1018
  flex-wrap: wrap;
1019
  gap: 6px;
1020
}
1021

1022
.action-buttons-right {
1023
  display: flex;
1024
  flex-wrap: wrap;
1025
  gap: 6px;
1026
  flex-shrink: 0;
1027
}
1028

1029
.action-btn {
1030
  display: inline-flex;
1031
  align-items: center;
1032
  gap: 4px;
1033
  padding: 6px 12px;
1034
  border: none;
1035
  border-radius: 20px;
1036
  font-size: 0.8rem;
1037
  font-weight: 500;
1038
  cursor: pointer;
1039
  transition: all 0.2s;
1040

1041
  &--primary {
1042
    background: $color-green-background;
1043
    color: white;
1044

1045
    &:hover {
1046
      background: darken($color-green-background, 10%);
1047
    }
1048
  }
1049

1050
  &--secondary {
1051
    background: $color-blue--bright;
1052
    color: white;
1053

1054
    &:hover {
1055
      background: darken($color-blue--bright, 10%);
1056
    }
1057
  }
1058

1059
  &--warning {
1060
    background: $color-orange--dark;
1061
    color: white;
1062

1063
    &:hover {
1064
      background: darken($color-orange--dark, 10%);
1065
    }
1066
  }
1067

1068
  &--light {
1069
    background: $color-gray--lighter;
1070
    color: $color-gray--dark;
1071

1072
    &:hover {
1073
      background: darken($color-gray--lighter, 10%);
1074
    }
1075
  }
1076
}
1077

1078
.replies-section {
1079
  padding: 0;
1080
}
1081

1082
.replies-header {
1083
  display: flex;
1084
  justify-content: space-between;
1085
  align-items: center;
1086
  padding: 12px 14px;
1087
  cursor: pointer;
1088
  background: lighten($color-green-background, 45%);
1089
  border-left: 4px solid $color-green-background;
1090

1091
  &:hover {
1092
    background: lighten($color-green-background, 40%);
1093
  }
1094
}
1095

1096
.replies-avatars {
1097
  display: flex;
1098
  align-items: center;
1099
  height: 36px;
1100
}
1101

1102
.reply-avatar {
1103
  margin-left: -8px;
1104
  position: relative;
1105
  width: 32px;
1106
  height: 32px;
1107

1108
  &:first-child {
1109
    margin-left: 0;
1110
  }
1111

1112
  :deep(.ProfileImage__container) {
1113
    width: 32px !important;
1114
    height: 32px !important;
1115
  }
1116

1117
  :deep(.profile) {
1118
    width: 32px !important;
1119
    height: 32px !important;
1120
    display: block !important;
1121
  }
1122

1123
  :deep(.circle) {
1124
    width: 32px !important;
1125
    height: 32px !important;
1126
    border: 2px solid white;
1127
    border-radius: 50%;
1128
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
1129
    overflow: hidden;
1130
    display: block !important;
1131
  }
1132

1133
  :deep(picture) {
1134
    width: 32px !important;
1135
    height: 32px !important;
1136
    display: block !important;
1137
    border: 2px solid white;
1138
    border-radius: 50%;
1139
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
1140
    overflow: hidden;
1141
  }
1142

1143
  :deep(img) {
1144
    width: 100% !important;
1145
    height: 100% !important;
1146
    object-fit: cover;
1147
    display: block !important;
1148
    border: none !important;
1149
    border-radius: 50%;
1150
  }
1151

1152
  :deep(.generated-avatar) {
1153
    width: 32px !important;
1154
    height: 32px !important;
1155
    min-width: 32px !important;
1156
    min-height: 32px !important;
1157
    border: 2px solid white;
1158
    border-radius: 50%;
1159
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
1160
    overflow: hidden;
1161

1162
    svg {
1163
      width: 32px !important;
1164
      height: 32px !important;
1165
      display: block !important;
1166
    }
1167
  }
1168
}
1169

1170
.more-count {
1171
  width: 32px;
1172
  height: 32px;
1173
  border-radius: 50%;
1174
  background: $color-green-background;
1175
  display: flex;
1176
  align-items: center;
1177
  justify-content: center;
1178
  font-size: 0.75rem;
1179
  font-weight: 600;
1180
  color: white;
1181
  margin-left: 4px;
1182
  border: 2px solid white;
1183
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
1184
  z-index: 10;
1185
  position: relative;
1186
}
1187

1188
.replies-text {
1189
  display: flex;
1190
  align-items: center;
1191
  gap: 8px;
1192
  background: $color-green-background;
1193
  color: white;
1194
  padding: 8px 14px;
1195
  border-radius: 20px;
1196
  font-weight: 600;
1197
}
1198

1199
.replies-count {
1200
  font-size: 0.85rem;
1201
  font-weight: 600;
1202
}
1203

1204
.expand-icon {
1205
  color: white;
1206
}
1207

1208
.replies-list {
1209
  padding: 0 12px 12px;
1210
}
1211

1212
.no-replies {
1213
  padding: 12px;
1214
  text-align: center;
1215
}
1216

1217
.replies-slide-enter-active,
1218
.replies-slide-leave-active {
1219
  transition: all 0.3s ease;
1220
  overflow: hidden;
1221
}
1222

1223
.replies-slide-enter-from,
1224
.replies-slide-leave-to {
1225
  opacity: 0;
1226
  max-height: 0;
1227
  transform: translateY(-10px);
1228
}
1229

1230
.replies-slide-enter-to,
1231
.replies-slide-leave-from {
1232
  opacity: 1;
1233
  max-height: 2000px;
1234
  transform: translateY(0);
1235
}
1236
</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