mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - implement EasyShare system
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
RefreshCw,
|
||||
Route,
|
||||
Settings2,
|
||||
Share2,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
type MapPoint,
|
||||
} from '@/features/map/defaultMapGeometry'
|
||||
import { useCrewLinkStore } from '@/stores/crewlink'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
CrewLinkColour,
|
||||
@@ -240,6 +242,20 @@ function showToast(message: string): void {
|
||||
}, 2400)
|
||||
}
|
||||
|
||||
function shareProfile(): void {
|
||||
const profile = crew.profile
|
||||
if (!profile) return
|
||||
useEasyShareStore().open({
|
||||
appId: 'crewlink',
|
||||
copyText: `@${profile.username}`,
|
||||
id: profile.id,
|
||||
kind: 'profile',
|
||||
link: `skyphone://crewlink/profile/${profile.id}`,
|
||||
subtitle: activeGroup.value?.name,
|
||||
title: `@${profile.username}`,
|
||||
})
|
||||
}
|
||||
|
||||
function updateValue(
|
||||
target: 'username' | 'groupName' | 'inviteCode' | 'pingLabel',
|
||||
event: Event,
|
||||
@@ -900,6 +916,17 @@ onBeforeUnmount(() => {
|
||||
<div><small>{{ t('yourCrewLinkId') }}</small><h1>@{{ crew.profile.username }}</h1><p>{{ activeGroup.name }} · {{ roleLabel(activeGroup.role) }}</p></div>
|
||||
</div>
|
||||
|
||||
<k-list inset strong>
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
:title="phone.t('Apps.easyShare.shareProfile')"
|
||||
@click="shareProfile"
|
||||
>
|
||||
<template #media><Share2 :size="20" /></template>
|
||||
</k-list-item>
|
||||
</k-list>
|
||||
|
||||
<k-block-title>{{ t('privacyVisibility') }}</k-block-title>
|
||||
<k-list inset strong>
|
||||
<k-list-item :title="t('shareOnMap')" :subtitle="t('shareOnMapBody')">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Plus,
|
||||
QrCode,
|
||||
Reply,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
@@ -45,13 +46,14 @@ import {
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import DarkChatVoiceMessage from '@/components/DarkChatVoiceMessage.vue'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -97,6 +99,7 @@ const darkSheetColors = {
|
||||
}
|
||||
const account = useAccountStore()
|
||||
const darkchat = useDarkChatStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const sms = useMessagesStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const phone = usePhoneStore()
|
||||
@@ -107,6 +110,7 @@ const identifier = ref('')
|
||||
const pendingIdentifier = ref('')
|
||||
const safetyOpen = ref(false)
|
||||
const draft = ref('')
|
||||
const queuedShareBody = ref('')
|
||||
const aliasDraft = ref('')
|
||||
const contactAliasDraft = ref('')
|
||||
const notificationMode = ref<DarkChatNotificationMode>('private')
|
||||
@@ -326,6 +330,10 @@ async function openConversation(conversationId: string): Promise<void> {
|
||||
}
|
||||
screen.value = 'thread'
|
||||
resetPanels()
|
||||
if (queuedShareBody.value) {
|
||||
draft.value = queuedShareBody.value
|
||||
queuedShareBody.value = ''
|
||||
}
|
||||
await scrollBottom(false)
|
||||
}
|
||||
|
||||
@@ -498,6 +506,45 @@ function copyMessage(message: DarkChatMessage): void {
|
||||
showToast(copyText(message.body) ? t('copied') : errorText())
|
||||
}
|
||||
|
||||
async function openEasyShareDraft(): Promise<boolean> {
|
||||
const shared = easyShare.consumeChatDraft('darkchat')
|
||||
if (!shared) return false
|
||||
if (!shared.targetId) {
|
||||
darkchat.closeThread()
|
||||
screen.value = 'inbox'
|
||||
resetPanels()
|
||||
queuedShareBody.value = shared.body
|
||||
draft.value = ''
|
||||
return true
|
||||
}
|
||||
if (!(await darkchat.openThread(shared.targetId))) {
|
||||
showToast(errorText(darkchat.lastError ?? undefined))
|
||||
return true
|
||||
}
|
||||
screen.value = 'thread'
|
||||
resetPanels()
|
||||
queuedShareBody.value = ''
|
||||
draft.value = shared.body
|
||||
await scrollBottom(false)
|
||||
return true
|
||||
}
|
||||
|
||||
function shareMessage(message: DarkChatMessage): void {
|
||||
selectedMessage.value = null
|
||||
useEasyShareStore().open({
|
||||
appId: 'darkchat',
|
||||
copyText: message.body || message.messageType,
|
||||
id: message.id,
|
||||
imageUrl:
|
||||
message.messageType === 'image' || message.messageType === 'video'
|
||||
? message.mediaPayload
|
||||
: undefined,
|
||||
kind: 'text',
|
||||
subtitle: active.value?.peer.alias,
|
||||
title: message.body || message.messageType,
|
||||
})
|
||||
}
|
||||
|
||||
async function react(
|
||||
message: DarkChatMessage,
|
||||
reaction: string,
|
||||
@@ -648,6 +695,20 @@ function chooseSelection(value: number | string): void {
|
||||
selectionSheet.value = null
|
||||
}
|
||||
|
||||
function shareIdentity(): void {
|
||||
const profile = darkchat.profile
|
||||
if (!profile) return
|
||||
easyShare.open({
|
||||
appId: 'darkchat',
|
||||
copyText: `${profile.alias}\n${profile.darkId}\n${profile.inviteCode}`,
|
||||
id: profile.id,
|
||||
kind: 'profile',
|
||||
link: `skyphone://darkchat/invite/${profile.inviteCode}`,
|
||||
subtitle: profile.darkId,
|
||||
title: profile.alias,
|
||||
})
|
||||
}
|
||||
|
||||
function copyIdentity(value: string): void {
|
||||
showToast(copyText(value) ? t('copied') : errorText())
|
||||
}
|
||||
@@ -818,12 +879,20 @@ function recordingTime(): string {
|
||||
|
||||
onMounted(async () => {
|
||||
if (signedIn.value) await darkchat.bootstrap()
|
||||
if (await openEasyShareDraft()) return
|
||||
if (!active.value) return
|
||||
screen.value = 'thread'
|
||||
const media = messageMedia.consume(`darkchat:${active.value.id}`)
|
||||
if (media) await sendAttachment(media)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => easyShare.chatDraft,
|
||||
(shared) => {
|
||||
if (shared?.appId === 'darkchat') void openEasyShareDraft()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
discardRecording = true
|
||||
cleanupRecording()
|
||||
@@ -2296,8 +2365,8 @@ onBeforeUnmount(() => {
|
||||
<k-button
|
||||
tonal
|
||||
rounded
|
||||
@click="copyIdentity(darkchat.profile.inviteCode)"
|
||||
><Copy :size="16" />{{ darkchat.profile.inviteCode }}</k-button
|
||||
@click="shareIdentity"
|
||||
><Share2 :size="16" />{{ phone.t('Apps.easyShare.shareProfile') }}</k-button
|
||||
>
|
||||
</k-card>
|
||||
</div>
|
||||
@@ -2770,6 +2839,14 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
<k-list inset strong class="dc-action-list">
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
content-class="w-full"
|
||||
:title="phone.t('Apps.easyShare.name')"
|
||||
@click="shareMessage(selectedMessage)"
|
||||
><template #media><Share2 :size="20" /></template
|
||||
></k-list-item>
|
||||
<k-list-item
|
||||
link
|
||||
link-component="button"
|
||||
|
||||
@@ -61,6 +61,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import FeatherPostCard from '@/components/feather/FeatherPostCard.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useFeatherStore } from '@/stores/feather'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import type { FeatherConnectionMode } from '@/stores/feather'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -417,25 +418,32 @@ async function selectTopic(topic: string): Promise<void> {
|
||||
await runSearch()
|
||||
}
|
||||
|
||||
async function shareProfile(): Promise<void> {
|
||||
if (!activeProfile.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(`@${activeProfile.value.handle}`)
|
||||
toast('profileCopied')
|
||||
} catch (error) {
|
||||
console.error('[Feather] Could not copy the profile handle.', error)
|
||||
toast('errors.generic')
|
||||
}
|
||||
function shareProfile(): void {
|
||||
const profile = activeProfile.value
|
||||
if (!profile) return
|
||||
useEasyShareStore().open({
|
||||
appId: 'feather',
|
||||
copyText: `@${profile.handle}`,
|
||||
id: profile.id,
|
||||
imageUrl: profile.avatar_url,
|
||||
kind: 'profile',
|
||||
link: `skyphone://feather/profile/${profile.id}`,
|
||||
subtitle: `@${profile.handle}`,
|
||||
title: profile.display_name,
|
||||
})
|
||||
}
|
||||
|
||||
async function sharePost(post: FeatherPost): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`@${post.handle}: ${post.body}`)
|
||||
toast('postCopied')
|
||||
} catch (error) {
|
||||
console.error('[Feather] Could not copy the post.', error)
|
||||
toast('errors.generic')
|
||||
}
|
||||
function sharePost(post: FeatherPost): void {
|
||||
useEasyShareStore().open({
|
||||
appId: 'feather',
|
||||
copyText: `@${post.handle}: ${post.body}`,
|
||||
id: post.id,
|
||||
imageUrl: post.media[0]?.url,
|
||||
kind: 'post',
|
||||
link: `skyphone://feather/post/${post.id}`,
|
||||
subtitle: `@${post.handle}`,
|
||||
title: post.body,
|
||||
})
|
||||
}
|
||||
|
||||
function openComposer(post?: FeatherPost): void {
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
RotateCcw,
|
||||
Search,
|
||||
Settings2,
|
||||
Share2,
|
||||
Star,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
@@ -59,12 +60,14 @@ import {
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import profilesSprite from '@/assets/img/flare/profiles-source.png'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useFlareStore } from '@/stores/flare'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
@@ -97,6 +100,7 @@ type FlareMediaContext = {
|
||||
type FlareChatMediaContext = { matchId: string }
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const flare = useFlareStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const messages = useMessagesStore()
|
||||
@@ -128,6 +132,21 @@ const gifResults = ref<GifSearchResult[]>([])
|
||||
const gifLoading = ref(false)
|
||||
const gifError = ref<string | null>(null)
|
||||
const gifHasMore = ref(true)
|
||||
|
||||
function shareProfile(): void {
|
||||
const profile = flare.profile
|
||||
if (!profile) return
|
||||
easyShare.open({
|
||||
appId: 'flare',
|
||||
copyText: `${profile.name}, ${profile.age}\n${profile.bio}`,
|
||||
id: profile.id,
|
||||
imageUrl: profile.photoUrls[0],
|
||||
kind: 'profile',
|
||||
link: `skyphone://flare/profile/${profile.id}`,
|
||||
subtitle: phone.t(`Apps.flare.lookingFor.${profile.lookingFor}`),
|
||||
title: `${profile.name}, ${profile.age}`,
|
||||
})
|
||||
}
|
||||
const gifNextOffset = ref(0)
|
||||
const draftPhotos = ref<FlareDraftPhoto[]>([])
|
||||
const activeChoiceField = ref<FlareChoiceField>('gender')
|
||||
@@ -568,6 +587,19 @@ async function openMatch(match: FlareMatch): Promise<void> {
|
||||
messageScroll.value?.scrollTo({ top: messageScroll.value.scrollHeight })
|
||||
}
|
||||
|
||||
async function openEasyShareDraft(): Promise<boolean> {
|
||||
const shared = easyShare.consumeChatDraft('flare')
|
||||
if (!shared?.targetId) return false
|
||||
const match = flare.matches.find((item) => item.id === shared.targetId)
|
||||
if (!match) {
|
||||
showActionError()
|
||||
return true
|
||||
}
|
||||
await openMatch(match)
|
||||
if (activeMatch.value?.id === match.id) draft.value = shared.body
|
||||
return true
|
||||
}
|
||||
|
||||
function closeMatch(): void {
|
||||
activeMatch.value = null
|
||||
draft.value = ''
|
||||
@@ -739,6 +771,7 @@ function messageTime(value: DatabaseDateValue): string {
|
||||
|
||||
onMounted(async () => {
|
||||
await flare.bootstrap()
|
||||
await openEasyShareDraft()
|
||||
const selection = messageMedia.consumeMany<FlareMediaContext>(
|
||||
'flare:profile-photos',
|
||||
)
|
||||
@@ -790,6 +823,13 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => easyShare.chatDraft,
|
||||
(shared) => {
|
||||
if (shared?.appId === 'flare') void openEasyShareDraft()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (gifSearchTimer) clearTimeout(gifSearchTimer)
|
||||
})
|
||||
@@ -1539,6 +1579,10 @@ onBeforeUnmount(() => {
|
||||
<span><Pencil /></span>
|
||||
{{ phone.t('Apps.flare.editProfile') }}
|
||||
</button>
|
||||
<button type="button" @click="shareProfile">
|
||||
<span><Share2 /></span>
|
||||
{{ phone.t('Apps.easyShare.shareProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
<k-card :content-wrap="false" class="flare-profile-card">
|
||||
<span><Heart fill="currentColor" /></span>
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
|
||||
import { useFlipTokStore } from '@/stores/fliptok'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
@@ -604,8 +605,30 @@ function openActions(video: FlipTokVideo): void {
|
||||
async function shareVideo(video: FlipTokVideo): Promise<void> {
|
||||
const response = await nuiCall('fliptok:share', { id: video.id })
|
||||
if (response.success) video.share_count += 1
|
||||
await navigator.clipboard?.writeText(`fliptok://video/${video.id}`)
|
||||
notify(t('linkCopied'))
|
||||
useEasyShareStore().open({
|
||||
appId: 'fliptok',
|
||||
copyText: `@${video.handle}: ${video.caption}`,
|
||||
id: video.id,
|
||||
imageUrl: video.url,
|
||||
kind: 'post',
|
||||
link: `skyphone://fliptok/video/${video.id}`,
|
||||
subtitle: `@${video.handle}`,
|
||||
title: video.caption || video.display_name,
|
||||
})
|
||||
}
|
||||
|
||||
function shareCurrentProfile(): void {
|
||||
const profile = currentProfile.value
|
||||
if (!profile) return
|
||||
useEasyShareStore().open({
|
||||
appId: 'fliptok',
|
||||
copyText: `@${profile.handle}`,
|
||||
id: profile.id,
|
||||
kind: 'profile',
|
||||
link: `skyphone://fliptok/profile/${profile.id}`,
|
||||
subtitle: `@${profile.handle}`,
|
||||
title: profile.display_name,
|
||||
})
|
||||
}
|
||||
|
||||
async function reportVideo(): Promise<void> {
|
||||
@@ -1160,6 +1183,10 @@ onBeforeUnmount(() => {
|
||||
>{{ t('block') }}</k-button
|
||||
>
|
||||
</template>
|
||||
<k-button rounded tonal @click="shareCurrentProfile">
|
||||
<Share2 :size="17" />
|
||||
{{ phone.t('Apps.easyShare.shareProfile') }}
|
||||
</k-button>
|
||||
</div>
|
||||
<div class="profile-video-grid">
|
||||
<button
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
kSegmentedButton,
|
||||
kToast,
|
||||
} from 'konsta/vue'
|
||||
import { Play, RotateCcw, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
|
||||
import { Play, RotateCcw, Share2, Trash2, ZoomIn, ZoomOut } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import type { DeleteResult, GalleryFilter, PhoneMedia } from '@/types/media'
|
||||
import {
|
||||
hasNextMediaPage,
|
||||
@@ -37,6 +38,7 @@ const filterItems = [
|
||||
{ id: 'video', label: 'videos' },
|
||||
] as const
|
||||
const phone = usePhoneStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -255,6 +257,21 @@ function closeMedia(): void {
|
||||
stopDragging()
|
||||
}
|
||||
|
||||
function shareSelected(): void {
|
||||
if (!selected.value) return
|
||||
const mediaKind = selected.value.mediaType
|
||||
easyShare.open({
|
||||
appId: 'photos',
|
||||
copyText: selected.value.url,
|
||||
id: selected.value.id,
|
||||
imageUrl: selected.value.url,
|
||||
kind: mediaKind,
|
||||
link: `skyphone://media/${selected.value.id}`,
|
||||
subtitle: formatDate(selected.value.createdAt),
|
||||
title: phone.t(mediaKind === 'video' ? 'Apps.photos.video' : 'Apps.photos.photo'),
|
||||
})
|
||||
}
|
||||
|
||||
function orientToMedia(event: Event): void {
|
||||
const mediaElement = event.currentTarget as
|
||||
| HTMLImageElement
|
||||
@@ -332,6 +349,10 @@ async function deleteSelected(): Promise<void> {
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
const message = event.data as { data?: DeleteResult; type?: string }
|
||||
if (message.type === 'gallery:changed') {
|
||||
void loadGallery()
|
||||
return
|
||||
}
|
||||
if (
|
||||
message.type !== 'media:deleteResult' ||
|
||||
message.data?.correlationId !== pendingDeleteCorrelation
|
||||
@@ -519,6 +540,15 @@ onBeforeUnmount(() => {
|
||||
v-else
|
||||
component="button"
|
||||
icon-only
|
||||
:aria-label="phone.t('Apps.easyShare.name')"
|
||||
@click="shareSelected"
|
||||
>
|
||||
<Share2 :size="20" />
|
||||
</k-link>
|
||||
<k-link
|
||||
v-if="!requestedMessageMedia"
|
||||
component="button"
|
||||
icon-only
|
||||
class="text-red-500"
|
||||
:aria-label="phone.t('Apps.photos.delete')"
|
||||
:disabled="deleting"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MapPinPlus,
|
||||
Route,
|
||||
Satellite,
|
||||
Share2,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type MapPoint,
|
||||
} from '@/features/map/defaultMapGeometry'
|
||||
import { useMapStore } from '@/stores/map'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MapMarker, MapMarkerColor } from '@/types/map'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
@@ -40,6 +42,7 @@ type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mapStore = useMapStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const mapStyle = ref<MapStyle>('default')
|
||||
const zoom = ref(1.1)
|
||||
const pan = ref<MapPoint>({ x: 0, y: 0 })
|
||||
@@ -462,6 +465,16 @@ async function loadCurrentLocation(center: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function shareCurrentLocation(): void {
|
||||
easyShare.open({
|
||||
appId: 'map',
|
||||
copyText: phone.t('Apps.map.currentLocation'),
|
||||
kind: 'location',
|
||||
link: 'skyphone://location/current',
|
||||
title: phone.t('Apps.map.currentLocation'),
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrentLocation(false)
|
||||
void mapStore.load()
|
||||
@@ -552,6 +565,18 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<nav class="map-controls" :aria-label="phone.t('Apps.map.controls')">
|
||||
<k-fab
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control map-control--share"
|
||||
:colors="locationControlColors"
|
||||
:aria-label="phone.t('Apps.easyShare.name')"
|
||||
@click="shareCurrentLocation"
|
||||
>
|
||||
<template #icon>
|
||||
<Share2 aria-hidden="true" />
|
||||
</template>
|
||||
</k-fab>
|
||||
<k-fab
|
||||
component="button"
|
||||
type="button"
|
||||
|
||||
@@ -39,13 +39,14 @@ import {
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MessageAttachmentBubble from '@/components/MessageAttachmentBubble.vue'
|
||||
import FullEmojiPicker from '@/components/FullEmojiPicker.vue'
|
||||
import VoiceMessageBubble from '@/components/VoiceMessageBubble.vue'
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -63,6 +64,7 @@ const WAVEFORM_SAMPLES = 48
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const calls = useCallsStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const messages = useMessagesStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const router = useRouter()
|
||||
@@ -72,6 +74,7 @@ const editingList = ref(false)
|
||||
const selectedNumbers = ref<string[]>([])
|
||||
const composerNumber = ref('')
|
||||
const draft = ref('')
|
||||
const queuedShareBody = ref('')
|
||||
const composing = ref(false)
|
||||
const sending = ref(false)
|
||||
const toastOpened = ref(false)
|
||||
@@ -119,6 +122,14 @@ const filteredConversations = computed(() => {
|
||||
const knownContactNumbers = computed(
|
||||
() => new Set(calls.contacts.map((contact) => contact.phone_number)),
|
||||
)
|
||||
const contactAvatarUrls = computed(
|
||||
() =>
|
||||
new Map(
|
||||
calls.contacts
|
||||
.filter((contact) => Boolean(contact.avatar_url))
|
||||
.map((contact) => [contact.phone_number, contact.avatar_url as string]),
|
||||
),
|
||||
)
|
||||
const contactSuggestions = computed(() => {
|
||||
const query = composerNumber.value.trim().toLocaleLowerCase(phone.lang)
|
||||
if (!query) return calls.contacts.slice(0, 8)
|
||||
@@ -163,21 +174,17 @@ function conversationPreview(conversation: SmsConversation): string {
|
||||
: conversation.lastMessage
|
||||
}
|
||||
|
||||
function avatarStyle(number: string): { background: string } {
|
||||
let hash = 0
|
||||
for (const character of number) {
|
||||
hash = (hash * 31 + character.charCodeAt(0)) % 360
|
||||
}
|
||||
return {
|
||||
background: `linear-gradient(145deg, hsl(${hash} 72% 62%), hsl(${(hash + 35) % 360} 68% 48%))`,
|
||||
}
|
||||
}
|
||||
|
||||
function avatarGlyph(number: string): string {
|
||||
const glyphs = ['👩🏻', '🧔🏻', '👩🏽', '👨🏼', '👩🏼', '🧑🏾', '👨🏽', '👩🏾']
|
||||
let hash = 0
|
||||
for (const character of number) hash += character.charCodeAt(0)
|
||||
return glyphs[hash % glyphs.length]
|
||||
function contactInitials(number: string): string {
|
||||
const contact = calls.contacts.find(
|
||||
(entry) => entry.phone_number === number,
|
||||
)
|
||||
if (!contact) return ''
|
||||
return contact.name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
function formatConversationDate(value: DatabaseDateValue): string {
|
||||
@@ -319,7 +326,28 @@ async function openConversation(conversation: SmsConversation): Promise<void> {
|
||||
return
|
||||
}
|
||||
composing.value = false
|
||||
draft.value = ''
|
||||
draft.value = queuedShareBody.value
|
||||
queuedShareBody.value = ''
|
||||
await scrollToBottom(false)
|
||||
}
|
||||
|
||||
async function openEasyShareDraft(): Promise<void> {
|
||||
const shared = easyShare.consumeChatDraft('messages')
|
||||
if (!shared) return
|
||||
if (!shared.targetId) {
|
||||
messages.closeThread()
|
||||
composing.value = false
|
||||
queuedShareBody.value = shared.body
|
||||
draft.value = ''
|
||||
return
|
||||
}
|
||||
if (!(await messages.openThread(shared.targetId))) {
|
||||
showToast(errorText('invalid_number'))
|
||||
return
|
||||
}
|
||||
composing.value = false
|
||||
queuedShareBody.value = ''
|
||||
draft.value = shared.body
|
||||
await scrollToBottom(false)
|
||||
}
|
||||
|
||||
@@ -682,9 +710,9 @@ async function finishVoiceRecording(): Promise<void> {
|
||||
await scrollToBottom()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void messages.loadConversations()
|
||||
void calls.loadContacts()
|
||||
onMounted(async () => {
|
||||
await Promise.all([messages.loadConversations(), calls.loadContacts()])
|
||||
await openEasyShareDraft()
|
||||
if (messages.activeNumber) {
|
||||
const media = messageMedia.consume(messages.activeNumber)
|
||||
if (media) {
|
||||
@@ -696,6 +724,13 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => easyShare.chatDraft,
|
||||
(shared) => {
|
||||
if (shared?.appId === 'messages') void openEasyShareDraft()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
discardRecording = true
|
||||
cleanupRecorder()
|
||||
@@ -797,14 +832,19 @@ onBeforeUnmount(() => {
|
||||
conversation.phoneNumber,
|
||||
),
|
||||
}"
|
||||
:style="avatarStyle(conversation.phoneNumber)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
v-if="contactAvatarUrls.get(conversation.phoneNumber)"
|
||||
class="messages-avatar__image"
|
||||
:src="contactAvatarUrls.get(conversation.phoneNumber)"
|
||||
alt=""
|
||||
/>
|
||||
<span
|
||||
v-if="knownContactNumbers.has(conversation.phoneNumber)"
|
||||
class="messages-avatar__glyph"
|
||||
v-else-if="knownContactNumbers.has(conversation.phoneNumber)"
|
||||
class="messages-avatar__initials"
|
||||
>
|
||||
{{ avatarGlyph(conversation.phoneNumber) }}
|
||||
{{ contactInitials(conversation.phoneNumber) }}
|
||||
</span>
|
||||
<span v-else class="messages-avatar__placeholder">
|
||||
<i />
|
||||
@@ -937,10 +977,15 @@ onBeforeUnmount(() => {
|
||||
<template #media>
|
||||
<span
|
||||
class="messages-avatar messages-avatar--small"
|
||||
:style="avatarStyle(contact.phone_number)"
|
||||
>
|
||||
<span class="messages-avatar__glyph">{{
|
||||
avatarGlyph(contact.phone_number)
|
||||
<img
|
||||
v-if="contact.avatar_url"
|
||||
class="messages-avatar__image"
|
||||
:src="contact.avatar_url"
|
||||
alt=""
|
||||
/>
|
||||
<span v-else class="messages-avatar__initials">{{
|
||||
contactInitials(contact.phone_number)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -979,10 +1024,15 @@ onBeforeUnmount(() => {
|
||||
<span
|
||||
class="messages-avatar messages-avatar--header"
|
||||
:class="{ 'messages-avatar--unknown': !activeContact }"
|
||||
:style="avatarStyle(messages.activeNumber ?? '')"
|
||||
>
|
||||
<span v-if="activeContact" class="messages-avatar__glyph">{{
|
||||
avatarGlyph(messages.activeNumber ?? '')
|
||||
<img
|
||||
v-if="activeContact?.avatar_url"
|
||||
class="messages-avatar__image"
|
||||
:src="activeContact.avatar_url"
|
||||
alt=""
|
||||
/>
|
||||
<span v-else-if="activeContact" class="messages-avatar__initials">{{
|
||||
contactInitials(messages.activeNumber ?? '')
|
||||
}}</span>
|
||||
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
|
||||
<i />
|
||||
@@ -1026,10 +1076,15 @@ onBeforeUnmount(() => {
|
||||
<span
|
||||
class="messages-avatar messages-avatar--contact"
|
||||
:class="{ 'messages-avatar--unknown': !activeContact }"
|
||||
:style="avatarStyle(messages.activeNumber ?? '')"
|
||||
>
|
||||
<span v-if="activeContact" class="messages-avatar__glyph">{{
|
||||
avatarGlyph(messages.activeNumber ?? '')
|
||||
<img
|
||||
v-if="activeContact?.avatar_url"
|
||||
class="messages-avatar__image"
|
||||
:src="activeContact.avatar_url"
|
||||
alt=""
|
||||
/>
|
||||
<span v-else-if="activeContact" class="messages-avatar__initials">{{
|
||||
contactInitials(messages.activeNumber ?? '')
|
||||
}}</span>
|
||||
<span v-else class="messages-avatar__placeholder" aria-hidden="true">
|
||||
<i />
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Trash2,
|
||||
@@ -47,6 +48,7 @@ import type { CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useMusicStore } from '@/stores/music'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MusicPlaylist, MusicTrack } from '@/types/music'
|
||||
|
||||
@@ -65,6 +67,7 @@ const MUSIC_POPOVER_GAP = 7
|
||||
const MUSIC_SHEET_TRANSITION_MS = 420
|
||||
|
||||
const music = useMusicStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const phone = usePhoneStore()
|
||||
const activeTab = ref<MusicTab>('library')
|
||||
const activePlaylist = ref<MusicPlaylist | null>(null)
|
||||
@@ -189,7 +192,7 @@ function positionPopover(target: HTMLElement, itemCount: number): boolean {
|
||||
|
||||
function openAddMenu(event: MouseEvent): void {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (!positionPopover(target, activePlaylist.value ? 3 : 2)) return
|
||||
if (!positionPopover(target, activePlaylist.value ? 4 : 2)) return
|
||||
actionTrack.value = null
|
||||
actionMenuOpened.value = false
|
||||
addMenuOpened.value = true
|
||||
@@ -264,12 +267,44 @@ function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
|
||||
event.stopPropagation()
|
||||
actionTrack.value = track
|
||||
const itemCount =
|
||||
1 + (activePlaylist.value ? 1 : 0) + (track.source === 'youtube' ? 1 : 0)
|
||||
2 + (activePlaylist.value ? 1 : 0) + (track.source === 'youtube' ? 1 : 0)
|
||||
if (!positionPopover(event.currentTarget as HTMLElement, itemCount)) return
|
||||
addMenuOpened.value = false
|
||||
actionMenuOpened.value = true
|
||||
}
|
||||
|
||||
function shareTrack(): void {
|
||||
const track = actionTrack.value
|
||||
if (!track) return
|
||||
closeMenus()
|
||||
easyShare.open({
|
||||
appId: 'music',
|
||||
copyText: `${track.title} — ${track.artist}`,
|
||||
id: track.id,
|
||||
imageUrl: track.artwork,
|
||||
kind: 'track',
|
||||
link: `skyphone://music/${track.source}/${track.id}`,
|
||||
meta: { source: track.source },
|
||||
subtitle: track.artist,
|
||||
title: track.title,
|
||||
})
|
||||
}
|
||||
|
||||
function shareActivePlaylist(): void {
|
||||
const playlist = activePlaylist.value
|
||||
if (!playlist) return
|
||||
closeMenus()
|
||||
easyShare.open({
|
||||
appId: 'music',
|
||||
copyText: `${playlist.name} · ${playlist.entries.length}`,
|
||||
id: playlist.id,
|
||||
kind: 'playlist',
|
||||
link: `skyphone://music/playlist/${playlist.id}`,
|
||||
subtitle: phone.t('Apps.music.songCount', { count: String(playlist.entries.length) }),
|
||||
title: playlist.name,
|
||||
})
|
||||
}
|
||||
|
||||
function openPlaylist(playlist: MusicPlaylist): void {
|
||||
activePlaylist.value = playlist
|
||||
activeTab.value = 'playlists'
|
||||
@@ -934,6 +969,9 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<k-list nested>
|
||||
<template v-if="activePlaylist">
|
||||
<k-list-button link-component="button" @click="shareActivePlaylist">
|
||||
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
|
||||
</k-list-button>
|
||||
<k-list-button
|
||||
link-component="button"
|
||||
@click="openActivePlaylistTrackPicker"
|
||||
@@ -975,6 +1013,9 @@ onBeforeUnmount(() => {
|
||||
:aria-label="phone.t('Apps.music.songActions')"
|
||||
>
|
||||
<k-list nested>
|
||||
<k-list-button link-component="button" @click="shareTrack">
|
||||
<Share2 :size="18" /> {{ phone.t('Apps.easyShare.name') }}
|
||||
</k-list-button>
|
||||
<k-list-button
|
||||
link-component="button"
|
||||
@click="openPlaylistPicker(actionTrack)"
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
kPopover,
|
||||
kSearchbar,
|
||||
} from 'konsta/vue'
|
||||
import { Ellipsis, Pin, PinOff, SquarePen, Trash2 } from 'lucide-vue-next'
|
||||
import { Ellipsis, Pin, PinOff, Share2, SquarePen, Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
computed,
|
||||
type ComponentPublicInstance,
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
} from 'vue'
|
||||
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { Note } from '@/utils/notes'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const notes = useNotesStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const searchQuery = ref('')
|
||||
const editorId = ref<string | null>(null)
|
||||
const editorOpened = ref(false)
|
||||
@@ -179,6 +181,20 @@ function togglePinned(): void {
|
||||
menuOpened.value = false
|
||||
}
|
||||
|
||||
function shareNote(): void {
|
||||
const note = persistDraft()
|
||||
if (!note) return
|
||||
menuOpened.value = false
|
||||
easyShare.open({
|
||||
appId: 'notes',
|
||||
copyText: note.body || note.title,
|
||||
id: note.id,
|
||||
kind: 'note',
|
||||
subtitle: notePreview(note),
|
||||
title: noteTitle(note),
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -304,6 +320,10 @@ function togglePinned(): void {
|
||||
@backdropclick="menuOpened = false"
|
||||
>
|
||||
<k-list nested>
|
||||
<k-list-button link-component="button" :colors="pinActionColors" @click="shareNote">
|
||||
<Share2 :size="18" />
|
||||
{{ phone.t('Apps.easyShare.name') }}
|
||||
</k-list-button>
|
||||
<k-list-button
|
||||
link-component="button"
|
||||
:colors="pinActionColors"
|
||||
|
||||
@@ -45,6 +45,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -67,6 +68,7 @@ type ContactPhotoContext = {
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const calls = useCallsStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const mediaPicker = useMessageMediaStore()
|
||||
const messages = useMessagesStore()
|
||||
const router = useRouter()
|
||||
@@ -489,6 +491,33 @@ function openCallContact(): void {
|
||||
openContact(activeCallContact.value ?? undefined, call.otherNumber)
|
||||
}
|
||||
|
||||
function shareSelectedContact(): void {
|
||||
const contact = selectedContact.value
|
||||
if (!contact) return
|
||||
easyShare.open({
|
||||
appId: 'phone',
|
||||
copyText: `${contact.name}\n${contact.phone_number}`,
|
||||
id: contact.id,
|
||||
imageUrl: contact.avatar_url,
|
||||
kind: 'contact',
|
||||
subtitle: formatPhoneNumber(contact.phone_number),
|
||||
title: contact.name,
|
||||
})
|
||||
}
|
||||
|
||||
function shareOwnProfile(): void {
|
||||
const number = phone.device?.sim?.number
|
||||
if (!number) return
|
||||
easyShare.open({
|
||||
appId: 'phone',
|
||||
copyText: `${phone.t('Apps.phone.myCard')}\n${number}`,
|
||||
kind: 'profile',
|
||||
link: `skyphone://phone/${number}`,
|
||||
subtitle: formatPhoneNumber(number),
|
||||
title: phone.t('Apps.phone.myCard'),
|
||||
})
|
||||
}
|
||||
|
||||
function messageActiveCaller(): void {
|
||||
const number = calls.activeCall?.otherNumber
|
||||
callMoreOpened.value = false
|
||||
@@ -954,6 +983,11 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
</div>
|
||||
</k-glass>
|
||||
<k-glass class="phone-profile-card phone-profile-single-option">
|
||||
<button type="button" @click="shareOwnProfile">
|
||||
<span>{{ phone.t('Apps.easyShare.shareProfile') }}</span>
|
||||
</button>
|
||||
</k-glass>
|
||||
</section>
|
||||
|
||||
<section v-else class="phone-profile-content">
|
||||
@@ -984,7 +1018,11 @@ onBeforeUnmount(() => {
|
||||
<button type="button" @click="openMessage(selectedNumber)">
|
||||
<span>{{ phone.t('Apps.phone.sendMessage') }}</span>
|
||||
</button>
|
||||
<button type="button" disabled>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!selectedContact"
|
||||
@click="shareSelectedContact"
|
||||
>
|
||||
<span>{{ phone.t('Apps.phone.shareContact') }}</span>
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Share2,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
UserRound,
|
||||
@@ -58,6 +59,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { usePicstagramStore } from '@/stores/picstagram'
|
||||
import type { PhoneMedia } from '@/types/media'
|
||||
@@ -453,6 +455,35 @@ function showPostActions(post: PicstagramPost): void {
|
||||
actionsOpen.value = true
|
||||
}
|
||||
|
||||
function sharePost(post: PicstagramPost): void {
|
||||
useEasyShareStore().open({
|
||||
appId: 'picstagram',
|
||||
copyText: `@${post.handle}: ${post.caption}`,
|
||||
id: post.id,
|
||||
imageUrl: post.media[0]?.url,
|
||||
kind: 'post',
|
||||
link: `skyphone://picstagram/post/${post.id}`,
|
||||
subtitle: `@${post.handle}`,
|
||||
title: post.caption || post.display_name,
|
||||
})
|
||||
}
|
||||
|
||||
function shareCurrentProfile(): void {
|
||||
const profile = currentProfile.value
|
||||
if (!profile) return
|
||||
actionsOpen.value = false
|
||||
useEasyShareStore().open({
|
||||
appId: 'picstagram',
|
||||
copyText: `@${profile.handle}`,
|
||||
id: profile.id,
|
||||
imageUrl: profile.avatar_url,
|
||||
kind: 'profile',
|
||||
link: `skyphone://picstagram/profile/${profile.id}`,
|
||||
subtitle: `@${profile.handle}`,
|
||||
title: profile.display_name,
|
||||
})
|
||||
}
|
||||
|
||||
function startReport(
|
||||
type: PicstagramReportTarget,
|
||||
id: string,
|
||||
@@ -871,6 +902,9 @@ onBeforeUnmount(() => {
|
||||
@click="openComments(selectedPost)"
|
||||
><MessageCircle
|
||||
/></k-link>
|
||||
<k-link component="button" icon-only :aria-label="phone.t('Apps.easyShare.name')" @click="sharePost(selectedPost)">
|
||||
<Share2 />
|
||||
</k-link>
|
||||
</span>
|
||||
<k-link
|
||||
component="button"
|
||||
@@ -1022,6 +1056,9 @@ onBeforeUnmount(() => {
|
||||
@click="openComments(post)"
|
||||
><MessageCircle
|
||||
/></k-link>
|
||||
<k-link component="button" icon-only :aria-label="phone.t('Apps.easyShare.name')" @click="sharePost(post)">
|
||||
<Share2 />
|
||||
</k-link>
|
||||
</span>
|
||||
<k-link
|
||||
component="button"
|
||||
@@ -1811,6 +1848,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</k-actions-group>
|
||||
<k-actions-group v-else-if="currentProfile && !currentProfile.is_owner">
|
||||
<k-actions-button @click="shareCurrentProfile">{{ phone.t('Apps.easyShare.name') }}</k-actions-button>
|
||||
<k-actions-button @click="reportCurrentProfile">{{
|
||||
t('report')
|
||||
}}</k-actions-button>
|
||||
@@ -1819,6 +1857,7 @@ onBeforeUnmount(() => {
|
||||
}}</k-actions-button>
|
||||
</k-actions-group>
|
||||
<k-actions-group v-else-if="currentProfile?.is_owner">
|
||||
<k-actions-button @click="shareCurrentProfile">{{ phone.t('Apps.easyShare.name') }}</k-actions-button>
|
||||
<k-actions-button @click="editProfileFromActions">{{
|
||||
t('editProfile')
|
||||
}}</k-actions-button>
|
||||
|
||||
Reference in New Issue
Block a user