Merge branch 'dev' into 'main'
Build 88 Merge from dev See merge request keanuapp/keanuapp-weblite!373
This commit is contained in:
commit
51c68269cb
86 changed files with 2178 additions and 9757 deletions
8947
package-lock.json
generated
8947
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "keanuapp-weblite",
|
||||
"version": "0.1.72",
|
||||
"version": "0.1.88",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
@ -45,6 +45,7 @@
|
|||
"vue-3-sanitize": "^0.1.4",
|
||||
"vue-clipboard2": "^0.3.3",
|
||||
"vue-i18n": "^11.1.3",
|
||||
"vue-pdf-embed": "^2.1.3",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-swipeable-bottom-sheet": "^0.0.5",
|
||||
"vue3-emoji-picker": "^1.1.8",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "keanuapp-weblite",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.87",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
@ -45,6 +45,7 @@
|
|||
"vue-3-sanitize": "^0.1.4",
|
||||
"vue-clipboard2": "^0.3.3",
|
||||
"vue-i18n": "^11.1.3",
|
||||
"vue-pdf-embed": "^2.1.3",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-swipeable-bottom-sheet": "^0.0.5",
|
||||
"vue3-emoji-picker": "^1.1.8",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ $hiliteColor: #4642f1;
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&.file-drop {
|
||||
flex: 0 0 100px;
|
||||
background-color: transparent;
|
||||
flex-direction: column;
|
||||
.file-drop-title {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
|
|
@ -58,6 +66,10 @@ $hiliteColor: #4642f1;
|
|||
height: $large-button-height;
|
||||
border-radius: $large-button-height * 0.5;
|
||||
}
|
||||
|
||||
&.text {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.back-button {
|
||||
|
|
@ -97,25 +109,24 @@ $hiliteColor: #4642f1;
|
|||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.send-attachments__selecting__current-item__preparing {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.send-attachments__selecting__current-item__cc {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
.file-drop-choose-files {
|
||||
background-color: $backgroundSection;
|
||||
border-radius: 19px;
|
||||
padding: 16px 18px;
|
||||
flex: 0 0 40%;
|
||||
margin: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
justify-content: end;
|
||||
.file-format-info {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 11 * $chat-text-size;
|
||||
line-height: 117%;
|
||||
color: rgba(white, 0.6);
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -516,6 +527,13 @@ $hiliteColor: #4642f1;
|
|||
.attachment-info {
|
||||
text-align: start;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.attachment-info__content {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.attachment-info__quality {
|
||||
.attachment-info__quality__title {
|
||||
|
|
@ -607,21 +625,6 @@ $hiliteColor: #4642f1;
|
|||
}
|
||||
}
|
||||
|
||||
.detail-subtitle {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 125%;
|
||||
letter-spacing: 0.4px;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 4px;
|
||||
a, a:visited {
|
||||
font-weight: 700;
|
||||
color: #8A87FF;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
margin-top: 12px;
|
||||
font-family: "Inter", sans-serif;
|
||||
|
|
@ -633,11 +636,13 @@ $hiliteColor: #4642f1;
|
|||
display: flex;
|
||||
|
||||
.v-icon {
|
||||
color: #dad9fc;
|
||||
padding: 9.33px;
|
||||
margin-right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: black;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detail-row__text {
|
||||
|
|
@ -656,4 +661,35 @@ $hiliteColor: #4642f1;
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-info__verify-info {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 125%;
|
||||
letter-spacing: 0.4px;
|
||||
margin-top: 22px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
a,
|
||||
a:visited {
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
text-decoration: underline !important;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-info__download-button {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 14 * $chat-text-size;
|
||||
color: white;
|
||||
background-color: transparent;
|
||||
border: 1px solid white;
|
||||
border-radius: 24 * $chat-text-size;
|
||||
height: 48 * $chat-text-size;
|
||||
|
||||
flex: 0 0 (48 * $chat-text-size);
|
||||
}
|
||||
}
|
||||
10
src/assets/icons/ic_copy.vue
Normal file
10
src/assets/icons/ic_copy.vue
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" stroke="currentColor" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5 2H9.73333C11.2268 2 11.9735 2 12.544 2.29065C13.0457 2.54631 13.4537 2.95426 13.7094 3.45603C14 4.02646 14 4.77319 14 6.26667V11M4.13333 14H9.53333C10.2801 14 10.6534 14 10.9387 13.8547C11.1895 13.7268 11.3935 13.5229 11.5213 13.272C11.6667 12.9868 11.6667 12.6134 11.6667 11.8667V6.46667C11.6667 5.71993 11.6667 5.34656 11.5213 5.06135C11.3935 4.81046 11.1895 4.60649 10.9387 4.47866C10.6534 4.33333 10.2801 4.33333 9.53333 4.33333H4.13333C3.3866 4.33333 3.01323 4.33333 2.72801 4.47866C2.47713 4.60649 2.27316 4.81046 2.14532 5.06135C2 5.34656 2 5.71993 2 6.46667V11.8667C2 12.6134 2 12.9868 2.14532 13.272C2.27316 13.5229 2.47713 13.7268 2.72801 13.8547C3.01323 14 3.3866 14 4.13333 14Z"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
@ -2,14 +2,14 @@
|
|||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14.1487 15.7001H0.822597C0.376044 15.7001 0 15.3241 0 14.8775C0 14.431 0.376044 14.0549 0.822597 14.0549H14.1487C14.5952 14.0549 14.9713 14.431 14.9713 14.8775C14.9713 15.3241 14.5952 15.7001 14.1487 15.7001Z"
|
||||
fill="#161616" />
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M7.4974 12.1509C7.05085 12.1509 6.6748 11.7749 6.6748 11.3283V0.822597C6.6748 0.376044 7.05085 0 7.4974 0C7.94395 0 8.32 0.376044 8.32 0.822597V11.3283C8.32 11.7749 7.94395 12.1509 7.4974 12.1509Z"
|
||||
fill="#161616" />
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M7.49734 12.151C7.28581 12.151 7.07429 12.0805 6.90977 11.916L3.05531 8.03806C2.72627 7.70902 2.72627 7.19196 3.05531 6.88643C3.38435 6.55739 3.90141 6.55739 4.20695 6.88643L8.0614 10.7409C8.39044 11.0699 8.39044 11.587 8.0614 11.8925C7.92039 12.0805 7.70886 12.151 7.49734 12.151Z"
|
||||
fill="#161616" />
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M7.49739 12.151C7.28586 12.151 7.07434 12.0805 6.90982 11.916C6.58078 11.587 6.58078 11.0699 6.90982 10.7644L10.7878 6.88643C11.1168 6.55739 11.6339 6.55739 11.9394 6.88643C12.2685 7.21547 12.2685 7.73253 11.9394 8.03806L8.08496 11.916C7.92044 12.0805 7.70891 12.151 7.49739 12.151Z"
|
||||
fill="#161616" />
|
||||
fill="currentColor" />
|
||||
</svg></template>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<style>
|
||||
<style scoped>
|
||||
path {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<g clip-path="url(#clip1_770_11697)">
|
||||
<path
|
||||
d="M1.5 3.9C1.5 3.05992 1.5 2.63988 1.66349 2.31901C1.8073 2.03677 2.03677 1.8073 2.31901 1.66349C2.63988 1.5 3.05992 1.5 3.9 1.5H8.1C8.94008 1.5 9.36012 1.5 9.68099 1.66349C9.96323 1.8073 10.1927 2.03677 10.3365 2.31901C10.5 2.63988 10.5 3.05992 10.5 3.9V6.75C10.5 7.44891 10.5 7.79837 10.3858 8.07403C10.2336 8.44157 9.94157 8.73358 9.57403 8.88582C9.29837 9 8.94891 9 8.25 9C8.00571 9 7.88357 9 7.77025 9.02675C7.61915 9.06242 7.47844 9.13278 7.35925 9.23225C7.26986 9.30685 7.19657 9.40457 7.05 9.6L6.32 10.5733C6.21144 10.7181 6.15716 10.7905 6.09062 10.8163C6.03233 10.839 5.96767 10.839 5.90938 10.8163C5.84284 10.7905 5.78856 10.7181 5.68 10.5733L4.95 9.6C4.80343 9.40457 4.73014 9.30685 4.64075 9.23225C4.52156 9.13278 4.38085 9.06242 4.22975 9.02675C4.11643 9 3.99429 9 3.75 9C3.05109 9 2.70163 9 2.42597 8.88582C2.05843 8.73358 1.76642 8.44157 1.61418 8.07403C1.5 7.79837 1.5 7.44891 1.5 6.75V3.9Z"
|
||||
stroke="#4642F1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<g clip-path="url(#clip1_770_11705)">
|
||||
<path
|
||||
d="M4.5 5.5L5.5 6.5L7.75 4.25M4.95 9.6L5.68 10.5733C5.78856 10.7181 5.84284 10.7905 5.90938 10.8163C5.96767 10.839 6.03233 10.839 6.09062 10.8163C6.15716 10.7905 6.21144 10.7181 6.32 10.5733L7.05 9.6C7.19657 9.40457 7.26986 9.30685 7.35925 9.23225C7.47844 9.13278 7.61915 9.06242 7.77025 9.02675C7.88357 9 8.00571 9 8.25 9C8.94891 9 9.29837 9 9.57403 8.88582C9.94157 8.73358 10.2336 8.44157 10.3858 8.07403C10.5 7.79837 10.5 7.44891 10.5 6.75V3.9C10.5 3.05992 10.5 2.63988 10.3365 2.31901C10.1927 2.03677 9.96323 1.8073 9.68099 1.66349C9.36012 1.5 8.94008 1.5 8.1 1.5H3.9C3.05992 1.5 2.63988 1.5 2.31901 1.66349C2.03677 1.8073 1.8073 2.03677 1.66349 2.31901C1.5 2.63988 1.5 3.05992 1.5 3.9V6.75C1.5 7.44891 1.5 7.79837 1.61418 8.07403C1.76642 8.44157 2.05843 8.73358 2.42597 8.88582C2.70163 9 3.05109 9 3.75 9C3.99429 9 4.11643 9 4.22975 9.02675C4.38085 9.06242 4.52156 9.13278 4.64075 9.23225C4.73014 9.30685 4.80343 9.40457 4.95 9.6Z"
|
||||
stroke="#4642F1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
|
|
|||
6
src/assets/icons/ic_report.vue
Normal file
6
src/assets/icons/ic_report.vue
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor">
|
||||
<path
|
||||
d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240ZM330-120 120-330v-300l210-210h300l210 210v300L630-120H330Zm34-80h232l164-164v-232L596-760H364L200-596v232l164 164Zm116-280Z" />
|
||||
</svg>
|
||||
</template>
|
||||
5
src/assets/icons/ic_video.vue
Normal file
5
src/assets/icons/ic_video.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 17C0 7.61116 7.60909 0 17 0C26.3888 0 34 7.60909 34 17C34 26.3888 26.3909 34 17 34C7.61116 34 0 26.3909 0 17ZM23.6512 17.8331L14.8714 23.585C14.1692 24.045 13.6 23.7419 13.6 22.9138V11.0867C13.6 10.256 14.1692 9.95554 14.8714 10.4155L23.6512 16.1674C24.3533 16.6274 24.3533 17.3732 23.6512 17.8331Z" fill="black"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
@ -124,7 +124,6 @@
|
|||
"processed_n_of_total_events": "تمت معالجة الوسائط لـ {count} من أصل {total} من الأحداث",
|
||||
"export_filename": "تم تصدير المحادثة {date}"
|
||||
},
|
||||
"language_display_name": "الإنجليزية",
|
||||
"global": {
|
||||
"save": "حفظ",
|
||||
"password_didnot_match": "كلمة السر غير متطابقة",
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@
|
|||
"user_make_moderator": "মডারেটর বানান",
|
||||
"user_revoke_moderator": "মডারেটর বাদ দিন"
|
||||
},
|
||||
"language_display_name": "ইংরেজি",
|
||||
"project": {
|
||||
"name": "Convene",
|
||||
"tag_line": "শুধুমাত্র সংযোগ করুন"
|
||||
|
|
|
|||
|
|
@ -281,7 +281,8 @@
|
|||
"sent_media": "སྨྱན་སྦྱོར་གྲངས་{count}་བཏང་ཟིན།",
|
||||
"room_upgraded": "ཁ་བརྡ་ཁང་འདི་རིམ་སྤར་བྱས་སོང་། {link}འབྲེལ་ཐག་འདི་བརྒྱུད་ནས་བསྐྱར་དུ་ཞུགས།",
|
||||
"room_upgraded_link": "འདི་ནས",
|
||||
"room_upgraded_view_old": "ཁ་བརྡ་ཁང་འདི་རིམ་སྤར་བྱས་སོང་། {link}འདིའི་ཐོག་ཏུ་མནན་ཏེ་ཆ་འཕྲིན་རྙིང་པར་གཟིགས།"
|
||||
"room_upgraded_view_old": "ཁ་བརྡ་ཁང་འདི་རིམ་སྤར་བྱས་སོང་། {link}འདིའི་ཐོག་ཏུ་མནན་ཏེ་ཆ་འཕྲིན་རྙིང་པར་གཟིགས།",
|
||||
"download_media": "སྨྱན་སྦྱོར་ཕབ་ལེན།"
|
||||
},
|
||||
"power_level": {
|
||||
"moderator": "མདོ་འཛིན་པ།",
|
||||
|
|
@ -359,7 +360,6 @@
|
|||
"close_tab": "བཤེར་ཆས་ཁ་རྒྱོབས།",
|
||||
"room_deleted": "ཁ་བརྡ་ཁང་མེད་པར་བཟོས་སོང་།"
|
||||
},
|
||||
"language_display_name": "བོད་ཡིག",
|
||||
"global": {
|
||||
"save": "ཉར་ཚགས།",
|
||||
"password_didnot_match": "གསང་ཚིག་མཐུན་གྱི་མི་འདུག",
|
||||
|
|
@ -377,10 +377,15 @@
|
|||
"notify": "བརྡ་ཁྱབ་གཏོང་བ།",
|
||||
"close": "སྒོ་རྒྱོབ།",
|
||||
"different_browser_title": "བཤེར་ཆས་གཞན་ཞིག་སྤྱོད་རོགས།",
|
||||
"different_browser_content": "ཁྱད་ཆོས་ཁག་ཅིག་ནུས་མེད་ཆགས་སྲིད་པས། འབྲེལ་ཐག་པར་བཤུས་བྱས་ཏེ། བཤེར་ཆས་གཞན་ཞིག་གི་ནང་དུ་ཁ་འབྱེད་རོགས།"
|
||||
"different_browser_content": "ཁྱད་ཆོས་ཁག་ཅིག་ནུས་མེད་ཆགས་སྲིད་པས། འབྲེལ་ཐག་པར་བཤུས་བྱས་ཏེ། བཤེར་ཆས་གཞན་ཞིག་གི་ནང་དུ་ཁ་འབྱེད་རོགས།",
|
||||
"retry": "བསྐྱར་དུ་ཚོད་ལྟ།"
|
||||
},
|
||||
"logout": {
|
||||
"confirm_text": "ཁྱེད་རང་ཁ་བརྡ་ཁང་ནས་ཕྱི་རུ་ཐོན་རྒྱུ་ཡིན་ནམ།"
|
||||
"confirm_text": "ཁྱེད་རང་ཁ་བརྡ་ཁང་ནས་ཕྱི་རུ་ཐོན་རྒྱུ་ཡིན་ནམ།",
|
||||
"copy_credentials": "རྒྱུན་སྤྱོད་མིང་དང་གསང་གྲངས་འདྲ་བཤུས་བྱོས།",
|
||||
"copied_credentials": "རྒྱུན་སྤྱོད་མིང་དང་གསང་གྲངས་འདྲ་བཤུས་བྱས་ཚར།",
|
||||
"copied_credentials_value": "རྒྱུན་སྤྱོད་མིང་། : {userId} \n\nགསང་གྲངས། : {password}",
|
||||
"copy_credentials_desc": "ཁ་པར་དང་བཤེར་ཆས་གསར་པ་ཞིག་ནས་ཁ་བརྡའི་ནང་འཛུལ་དགོས་ན། ཁྱེད་ཀྱི་རྒྱུན་སྤྱོད་མིང་དང་གསང་གྲངས་དགོས་པས། དེ་དག་གནས་བརྟན་པོ་ཞིག་ལ་ཉར་རྒྱུ་རེ་སྐུལ་ཞུ་གི་ཡོད།"
|
||||
},
|
||||
"poll_create": {
|
||||
"failed": "བསམ་ཚུལ་བསྡུ་ལེན་གྱི་འདེམས་ཤོག་བཟོ་ཐུབ་མ་སོང་། རྗེས་སུ་བསྐྱར་དུ་ཚོད་ལྟ་བྱོས།",
|
||||
|
|
@ -434,13 +439,17 @@
|
|||
"metadata_info_compressed": "པར་རིས་གནོན་བཙིར་བྱས་ན། དེའི་རྒྱུའི་གཞི་གྲངས་རང་བཞིན་གྱིས་འདོར་ངེས།",
|
||||
"metadata_info_original": "དང་ཐོག་གི་པར་གཞི་བརྒྱུད་སྐུར་བྱས་ན། དེའི་རྒྱུའི་གཞི་གྲངས་རང་བཞིན་གྱིས་ནང་དུ་ཚུད་འགྲོ།",
|
||||
"exif_data": "Exif གཞི་གྲངས།",
|
||||
"content_credentials": "ནང་དོན་བདེན་དཔང་།",
|
||||
"content_credentials_info": "སྨྱན་སྦྱོར་འདི་བདེན་དཔང་བྱེད་པར་ཁུངས་དང་ལོ་རྒྱུས་ཀྱི་ཆ་འཕྲིན་ཡོད།",
|
||||
"learn_more": "ཞིབ་ཕྲ་སློབ་སྦྱོང་བྱོས།",
|
||||
"ai_used": "མིས་བཟོས་རིག་ནུས་ཀྱིས་པར་རིས་བཅོས་སྒྱུར་བྱས་འདུག",
|
||||
"screenshot_taken_on": "{date}ཉིན་འཆར་ངོས་པར་བླངས་འདུག",
|
||||
"captured_with_camera": "པར་ཆས་ཤིག་གིས་པར་བླངས་འདུག",
|
||||
"old_photo": "ཟླ་བ་གསུམ་ལས་རྙིང་པའི་པར།"
|
||||
"captured_with_camera": "པར་ཆས་ངོ་མ་ཞིག་གིས་པར་བླངས་འདུག ",
|
||||
"old_photo": "ཟླ་བ་གསུམ་ལས་རྙིང་པའི་པར།. ",
|
||||
"cc_source": "ཁུངས།",
|
||||
"cc_capture_timestamp": "ལོ་ཟླ་ཚེས་གྲངས་འཛིན་པ།",
|
||||
"cc_location": "ས་གནས།",
|
||||
"screenshot": "བརྙན་ཤེལ་པར་རིས། ",
|
||||
"captured_screenshot": "བརྙན་ཤེལ་པར་རིས། ",
|
||||
"captured_screenshot_ago": "{ago} གོང་ལ་བརྙན་ཤེལ་པར་རིས་དེ་བླངས་འདུག "
|
||||
},
|
||||
"notification": {
|
||||
"dialog": {
|
||||
|
|
@ -506,5 +515,24 @@
|
|||
"filedrop_name": "ཁྱེད་ཀྱི་ཡིག་ཆ་འཇོག་སྒྲོམ་ལ་མིང་ཞིག་ཐོགས།",
|
||||
"status_creating": "ཡིག་ཆ་འཇོག་སྒྲོམ་བཟོ་བཞིན་པ།",
|
||||
"error_filedrop": "ཡིག་ཆ་འཇོག་སྒྲོམ་བཟོ་ཐུབ་མ་སོང་།"
|
||||
},
|
||||
"cc": {
|
||||
"details": "ཞིབ་ཕྲའི་གནས་ཚུལ།",
|
||||
"cc_info": "གནས་ཚུལ་མང་བ་ཤེས་འདོད་ན། པར་རིས་དེ་{link}ལ་ཡར་འཇུག་བྱོས།",
|
||||
"cc_info_link": "ཆ་འཕྲིན་གྱི་ནང་དོན་ར་སྤྲོད།",
|
||||
"cc_no_info": "པར་རིས་འདིའི་ཞིབ་ཕྲའི་གནས་ཚུལ་ར་སྤྲོད་བྱེད་ཐུབ་པ་མི་འདུག",
|
||||
"metadata-stripped": "པར་རིས་བཙིར་གནོན་བྱས་འདུག་པ་མ་ཟད། དེའི་རྒྱུའི་གཞི་གྲངས་ཡང་བསུབས་འདུག གལ་ཏེ། ཁྱེད་ལ་པར་འདིའི་རྒྱབ་ལྗོངས་སྐོར་མང་བ་དགོས་ཚེ། གཏོང་མཁན་ལ་པར་གྱི་མ་ཡིག་གཏོང་བའི་རེ་སྐུལ་ཞུ་རོགས།",
|
||||
"download_image": "པར་རིས་ཕབ་ལེན།",
|
||||
"contains_modified_and_ai_generated": "བཅོས་སྒྱུར་བྱས་ཤིང་མིས་བཟོས་རིག་ནུས་ཀྱིས་བཟོས་པའི་སྨྱན་སྦྱོར་འདུག",
|
||||
"take_a_closer_look": "ཞིབ་ཏུ་གཟིགས།",
|
||||
"contains_modified": "བཅོས་སྒྱུར་བྱས་པའི་སྨྱན་སྦྱོར་འདུག",
|
||||
"contains_ai_generated": "མིས་བཟོས་རིག་ནུས་ཀྱིས་བཟོས་པའི་སྨྱན་སྦྱོར་འདུག",
|
||||
"information_available": "མཉམ་སྤྱོད་བྱས་པའི་སྨྱན་སྦྱོར་སྐོར་གྱི་གནས་ཚུལ་ཡོད།",
|
||||
"captured_with_camera": "པར་ཆས་ཀྱིས་པར་བླངས་འདུག",
|
||||
"screenshot": "བརྙན་ཤེལ་པར་རིས།",
|
||||
"screenshot_probably": "པར་འདི་བརྙན་ཤེལ་པར་རིས་ཤིག་དང་འདྲ་བོ་འདུག",
|
||||
"generated_with_ai": "མིས་བཟོས་རིག་ནུས་ཀྱིས་བཟོས་འདུག",
|
||||
"modified": "བཅོས་སྒྱུར་བྱས་འདུག",
|
||||
"older_than_n_months": "ཟླ་བ་ {n} ལས་རྙིང་པ།"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"language_display_name": "Deutsch",
|
||||
"menu": {
|
||||
"start_private_chat": "Private Diskussion mit diesem Benutzer",
|
||||
"reply": "Antworten",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"language_display_name": "English",
|
||||
"project": {
|
||||
"name": "Convene",
|
||||
"tag_line": "Simply connect"
|
||||
|
|
@ -117,6 +116,7 @@
|
|||
"files": "Files",
|
||||
"images": "Images",
|
||||
"send_attachements_dialog_title": "Do you want to send following attachments?",
|
||||
"download_media": "Download media",
|
||||
"download_all": "Download all",
|
||||
"failed_to_render": "Failed to render event",
|
||||
"room_upgraded": "This room has been upgraded, go {link} to rejoin the discussion",
|
||||
|
|
@ -319,7 +319,15 @@
|
|||
"leave": "Leave"
|
||||
},
|
||||
"logout": {
|
||||
"confirm_text": "Are you sure you want to logout ?"
|
||||
"confirm_text": "Are you sure you want to logout ?",
|
||||
"copy_credentials": "Copy Username & Password",
|
||||
"copied_credentials": "Copied Username & Password",
|
||||
"copied_credentials_value": "Username: {userId} \nPassword: {password}",
|
||||
"copy_credentials_desc": "Your username and password are required to regain access to your chats from a new device or browser. We recommend storing these credentials in a secure location."
|
||||
},
|
||||
"delete_post": {
|
||||
"confirm_text": "Are you sure you want to delete this message?",
|
||||
"confirm_text_desc": "This action cannot be undone."
|
||||
},
|
||||
"purge_room": {
|
||||
"title": "Delete room?",
|
||||
|
|
@ -392,6 +400,7 @@
|
|||
"message_history_warning": "warning: Full message history will be visible to new participants",
|
||||
"report": "Report",
|
||||
"report_info": "Report this room to service administrators for illegal, abusive or otherwise harmful content",
|
||||
"report_event_info": "Report this message to service administrators for illegal, abusive or otherwise harmful content",
|
||||
"report_reason": "Reason",
|
||||
"confirm_make_admin": "Do you want to make this user an administrator?",
|
||||
"confirm_revoke_admin": "Do you want to remove administrator rights from this user?",
|
||||
|
|
@ -507,16 +516,28 @@
|
|||
"captured_with_camera": "Captured with a real camera. ",
|
||||
"captured_screenshot": "Screenshot. ",
|
||||
"captured_screenshot_ago": "Screenshot captured {ago} ago. ",
|
||||
"generated_with_ai": "Generated with AI. ",
|
||||
"generated_with_ai_ago": "Generated with AI {ago} ago. ",
|
||||
"old_photo": "Photo older than 3 months. ",
|
||||
"cc_source": "Source",
|
||||
"cc_capture_timestamp": "Capture Timestamp",
|
||||
"cc_location": "Location"
|
||||
},
|
||||
"cc": {
|
||||
"content_credentials": "Content Credentials",
|
||||
"content_credentials_info": "Source or history information is available for this media to be verified.",
|
||||
"metadata-stripped": "Image has been compressed and stripped of metadata.\n\nWe think this image has additional file information. If you want to learn more, ask the sender to share the original."
|
||||
"details": "Details",
|
||||
"cc_info": "More information is available. Upload the image to {link}",
|
||||
"cc_info_link": "Content Credentials Verify",
|
||||
"cc_no_info": "Details for this image cannot be verified.",
|
||||
"metadata-stripped": "The image has been compressed and its metadata removed. If you’d like more context, ask the sender for the original file.",
|
||||
"download_image": "Download image",
|
||||
"contains_modified_and_ai_generated": "Contains modified and AI generated media.",
|
||||
"take_a_closer_look": "Take a closer look.",
|
||||
"contains_modified": "Contains modified media.",
|
||||
"contains_ai_generated": "Contains AI generated media.",
|
||||
"information_available": "Information is available for the media shared.",
|
||||
"captured_with_camera": "Captured with a camera.",
|
||||
"screenshot": "Screenshot.",
|
||||
"screenshot_probably": "Image looks like a screenshot.",
|
||||
"generated_with_ai": "Generated with AI.",
|
||||
"modified": "Modified.",
|
||||
"older_than_n_months": "Older than {n} months."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"language_display_name": "Español",
|
||||
"project": {
|
||||
"name": "Convene",
|
||||
"tag_line": "Simplemente conectar"
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@
|
|||
"user_make_moderator": "Hacer moderador(a)",
|
||||
"user_revoke_moderator": "Revocar moderador(a)"
|
||||
},
|
||||
"language_display_name": "Inglés",
|
||||
"project": {
|
||||
"name": "Convene",
|
||||
"tag_line": "Simplemente conecta"
|
||||
|
|
|
|||
|
|
@ -178,7 +178,6 @@
|
|||
"places": "Lugares"
|
||||
}
|
||||
},
|
||||
"language_display_name": "Inglés",
|
||||
"project": {
|
||||
"name": "Convene",
|
||||
"tag_line": "Simplemente conéctate"
|
||||
|
|
|
|||
|
|
@ -90,14 +90,15 @@
|
|||
"send_attachements_dialog_title": "آیا میخواهید پیوستهای زیر را ارسال کنید؟",
|
||||
"download_all": "دانلود همه",
|
||||
"failed_to_render": "تحویل رویداد با شکست مواجه شد",
|
||||
"room_upgraded_link": "اینجا"
|
||||
"room_upgraded_link": "اینجا",
|
||||
"download_media": "بارگیری رسانه"
|
||||
},
|
||||
"new_room": {
|
||||
"next": "بعدی",
|
||||
"new_room": "اتاق جدید",
|
||||
"create": "ایجاد کنید",
|
||||
"name_room": "نام اتاق",
|
||||
"room_topic": "در صورت تمایل، توضیحی اضافه کنید.",
|
||||
"room_topic": "افزودن شرح دلخواه",
|
||||
"join_permissions": "مجوزهای پیوستن",
|
||||
"set_join_permissions": "مجوزهای پیوستن را تنظیم کنید",
|
||||
"join_permissions_info": "این مجوزها تعیین میکنند که چگونه افراد میتوانند به اتاق بپیوندند و دیگران چقدر راحت میتوانند دعوت شوند. این تنظیمات در هر زمان قابل تغییر هستند.",
|
||||
|
|
@ -158,9 +159,10 @@
|
|||
"quality": "کیفیت",
|
||||
"original": "اصلی",
|
||||
"learn_more": "بیشتر بدانید",
|
||||
"compressed": "فشرده"
|
||||
"compressed": "فشرده",
|
||||
"cc_source": "منبع",
|
||||
"cc_location": "مکان"
|
||||
},
|
||||
"language_display_name": "انگلیسی",
|
||||
"project": {
|
||||
"name": "تجمع",
|
||||
"tag_line": "به سادگی متصل شوید"
|
||||
|
|
@ -182,7 +184,8 @@
|
|||
"close": "ببندید",
|
||||
"notify": "اطلاع بدهید",
|
||||
"different_browser_title": "مرورگر متفاوتی را امتحان کنید",
|
||||
"different_browser_content": "برخی ویژگیها ممکن است از کار بیفتند. لینک را کپی کرده و در مرورگر دیگری باز کنید."
|
||||
"different_browser_content": "برخی ویژگیها ممکن است از کار بیفتند. لینک را کپی کرده و در مرورگر دیگری باز کنید.",
|
||||
"retry": "تلاش دوباره"
|
||||
},
|
||||
"room": {
|
||||
"unseen_messages": "شما هیچ پیام نادیدهای ندارید | شما 1 پیام نادیده دارید | شما {count} پیام نادیده دارید",
|
||||
|
|
@ -333,7 +336,7 @@
|
|||
"leave_room": "ترک کنید",
|
||||
"version_info": "پشتیبانی شده توسط پروژه Guardian. نسخه: {version}",
|
||||
"scan_code": "برای پیوستن به اتاق اسکن کنید",
|
||||
"export_room": "از چت، خروجی بگیرید",
|
||||
"export_room": "ذخیرهٔ گپ",
|
||||
"user_admin": "ادمین",
|
||||
"user_moderator": "ناظم",
|
||||
"moderation": "مدیریت",
|
||||
|
|
@ -344,11 +347,11 @@
|
|||
"voice_mode_info": "رابطِ چت را به حالت «گوش دادن و ضبط» تغییر میدهد.",
|
||||
"file_mode": "حالت فایل",
|
||||
"file_mode_info": "رابطِ چت را به حالت «کشیدن و رها کردن فایل» تغییر میدهد.",
|
||||
"download_chat": "دانلود چت",
|
||||
"download_chat": "ذخیرهٔ گپ",
|
||||
"read_only_room": "فقط خواندن",
|
||||
"read_only_room_info": "فقط ادمینها و ناظمین مجاز به ارسال پیام به اتاق هستند.",
|
||||
"message_retention": "تاریخچه پیام",
|
||||
"message_retention_info": "پیامهای ارسال شده در این بازه زمانی برای هر کسی که لینک را داشته باشد قابل مشاهده است.",
|
||||
"message_retention": "محدودیت تاریخچه",
|
||||
"message_retention_info": "تنظیم محدودیتی برای نگه داشتن پیامها",
|
||||
"message_retention_none": "خاموش",
|
||||
"message_retention_4_week": "4 هفته",
|
||||
"message_retention_2_week": "2 هفته",
|
||||
|
|
@ -364,8 +367,9 @@
|
|||
"message_history": "تاریخچه پیام",
|
||||
"report_reason": "دلیل",
|
||||
"report": "گزارش",
|
||||
"accept_knock": "قبول کنید",
|
||||
"reject_knock": "رد"
|
||||
"accept_knock": "پذیرش",
|
||||
"reject_knock": "رد",
|
||||
"user_creator": "سازنده"
|
||||
},
|
||||
"room_info_sheet": {
|
||||
"this_room": "این اتاق",
|
||||
|
|
@ -454,5 +458,13 @@
|
|||
"field_required": "این فیلد ضروری است",
|
||||
"room_type_channel_name": "کانال",
|
||||
"topic_too_long": "بیشینه ۵۰ نویسه مجاز است"
|
||||
},
|
||||
"cc": {
|
||||
"details": "جزییات",
|
||||
"screenshot": "نماگرفت.",
|
||||
"screenshot_probably": "تصویر شبیه به نماگرفت است.",
|
||||
"generated_with_ai": "ساخته شده با هوشی.",
|
||||
"modified": "دستکاری شده.",
|
||||
"older_than_n_months": "قدیمیتر از {n} ماه."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,7 +217,6 @@
|
|||
},
|
||||
"search": "جستجو..."
|
||||
},
|
||||
"language_display_name": "انگلیسی",
|
||||
"project": {
|
||||
"name": "تجمع",
|
||||
"tag_line": "بطور ساده وصل شوید"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@
|
|||
"video_file": "Videotiedosto",
|
||||
"download_name": "Lataus"
|
||||
},
|
||||
"language_display_name": "suomi",
|
||||
"device_list": {
|
||||
"verified": "Vahvistettu",
|
||||
"blocked": "Estetty",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
"user_make_admin": "Rendre administrateur",
|
||||
"direct_chat": "Discussion directe"
|
||||
},
|
||||
"language_display_name": "français",
|
||||
"message": {
|
||||
"you": "Vous",
|
||||
"user_created_room": "{user} a créé le salon",
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@
|
|||
"download_all": "Íoslódáil go léir",
|
||||
"room_upgraded": "Tá an seomra seo uasghrádaithe, téigh chuig {link} chun páirt a ghlacadh sa phlé arís",
|
||||
"room_upgraded_link": "anseo",
|
||||
"room_upgraded_view_old": "Rinneadh uasghrádú ar an seomra seo. Cliceáil {link} chun sean-theachtaireachtaí a fheiceáil"
|
||||
"room_upgraded_view_old": "Rinneadh uasghrádú ar an seomra seo. Cliceáil {link} chun sean-theachtaireachtaí a fheiceáil",
|
||||
"download_media": "Íoslódáil meáin"
|
||||
},
|
||||
"room_welcome": {
|
||||
"info_permissions": "Is féidir leat 'ceadanna páirti' a athrú ag am ar bith i socruithe an tseomra.",
|
||||
|
|
@ -287,12 +288,16 @@
|
|||
"metadata_info_compressed": "Má chomhbhrúitear an íomhá, eisiatar a meiteashonraí go huathoibríoch.",
|
||||
"metadata_info_original": "Cuirtear meiteashonraí an bhunchóip san áireamh go huathoibríoch nuair a roinntear é.",
|
||||
"exif_data": "Sonraí Exif",
|
||||
"content_credentials": "Dintiúir Ábhair",
|
||||
"content_credentials_info": "Tá faisnéis foinse nó staire ar fáil le go bhféadfaidh an meán seo a fhíorú.",
|
||||
"ai_used": "Grianghraf modhnaithe le hintleacht shaorga",
|
||||
"screenshot_taken_on": "Scáileán tógtha ar {date}",
|
||||
"captured_with_camera": "Gabhadh le ceamara",
|
||||
"old_photo": "Grianghraf níos sine ná 3 mhí"
|
||||
"captured_with_camera": "Gabhadh le ceamara fíor. ",
|
||||
"old_photo": "Grianghraf níos sine ná 3 mhí. ",
|
||||
"screenshot": "Seat scáileáin. ",
|
||||
"captured_screenshot": "Seat scáileáin ",
|
||||
"captured_screenshot_ago": "Gabhadh an seat scáileáin {ago} ó shin. ",
|
||||
"cc_source": "Foinse",
|
||||
"cc_capture_timestamp": "Gabháil Stampa Ama",
|
||||
"cc_location": "Suíomh"
|
||||
},
|
||||
"project": {
|
||||
"name": "Tionól",
|
||||
|
|
@ -315,9 +320,9 @@
|
|||
"close": "dún",
|
||||
"notify": "Tabhair fógra",
|
||||
"different_browser_title": "Bain triail as brabhsálaithe",
|
||||
"different_browser_content": "D'fhéadfadh roinnt gnéithe briseadh. Cóipeáil agus oscail nasc i mbrabhsálaí eile."
|
||||
"different_browser_content": "D'fhéadfadh roinnt gnéithe briseadh. Cóipeáil agus oscail nasc i mbrabhsálaí eile.",
|
||||
"retry": "Déan iarracht eile"
|
||||
},
|
||||
"language_display_name": "Béarla",
|
||||
"menu": {
|
||||
"start_private_chat": "Teachtaireacht Díreach leis an úsáideoir seo",
|
||||
"direct_chat": "Comhrá díreach",
|
||||
|
|
@ -474,7 +479,11 @@
|
|||
"restricted": "srianta"
|
||||
},
|
||||
"logout": {
|
||||
"confirm_text": "An bhfuil tú cinnte gur mhaith leat logáil amach?"
|
||||
"confirm_text": "An bhfuil tú cinnte gur mhaith leat logáil amach?",
|
||||
"copy_credentials": "Cóipeáil Ainm Úsáideora & Pasfhocal",
|
||||
"copied_credentials": "Ainm úsáideora & Pasfhocal cóipeáilte",
|
||||
"copied_credentials_value": "Ainm úsáideora: {userId} \nPasfhocal: {password}",
|
||||
"copy_credentials_desc": "Tá d’ainm úsáideora agus do phasfhocal ag teastáil chun rochtain a fháil ar do chomhráite arís ó ghléas nó brabhsálaí nua. Molaimid na dintiúir seo a stóráil in áit shábháilte."
|
||||
},
|
||||
"fallbacks": {
|
||||
"audio_file": "Comhad fuaime",
|
||||
|
|
@ -506,5 +515,24 @@
|
|||
"error_filedrop": "Theip ar chruthú anuas comhaid",
|
||||
"status_creating": "Titim comhaid a chruthú",
|
||||
"filedrop_name": "Ainmnigh do chomhad anuas"
|
||||
},
|
||||
"cc": {
|
||||
"metadata-stripped": "Tá an íomhá comhbhrúite agus a meiteashonraí bainte. Más mian leat tuilleadh comhthéacs, iarr an comhad bunaidh ar an seoltóir.",
|
||||
"generated_with_ai": "Gineadh le hintleacht shaorga.",
|
||||
"details": "Sonraí",
|
||||
"cc_info": "Tá tuilleadh eolais ar fáil. Uaslódáil an íomhá chuig {link}",
|
||||
"cc_info_link": "Fíoraigh Dintiúir Ábhair",
|
||||
"cc_no_info": "Ní féidir sonraí na híomhá seo a fhíorú.",
|
||||
"download_image": "Íoslódáil íomhá",
|
||||
"contains_modified_and_ai_generated": "Tá meáin mhodhnaithe agus ginte ag AI ann.",
|
||||
"take_a_closer_look": "Féach níos géire air.",
|
||||
"contains_modified": "Tá meáin mhodhnaithe ann.",
|
||||
"contains_ai_generated": "Tá meáin ghinte ag AI ann.",
|
||||
"information_available": "Tá faisnéis ar fáil do na meáin a roinntear.",
|
||||
"captured_with_camera": "Gabhadh le ceamara.",
|
||||
"screenshot": "Seat scáileáin.",
|
||||
"screenshot_probably": "Breathnaíonn an íomhá cosúil le seat scáileáin.",
|
||||
"modified": "Modhnaithe.",
|
||||
"older_than_n_months": "Níos sine ná {n} mí."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,7 +249,6 @@
|
|||
"video_file": "File video",
|
||||
"original_text": "<testo originale>"
|
||||
},
|
||||
"language_display_name": "italiano",
|
||||
"room_info_sheet": {
|
||||
"this_room": "Questa stanza",
|
||||
"view_details": "Visualizza i dettagli"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"language_display_name": "អង់គ្លេស",
|
||||
"global": {
|
||||
"save": "រក្សាទុក"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"language_display_name": "ئینگلیزی",
|
||||
"project": {
|
||||
"name": "کۆبوونەوە",
|
||||
"tag_line": "بە ئاسانی پەیوەندی بکە"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
"different_browser_title": "ລອງບຣາວເຊີອື່ນ",
|
||||
"different_browser_content": "ບາງຄຸນສົມບັດອາດຈະແຕກ. ສຳເນົາ ແລະ ເປີດລິ້ງໃນບຣາວເຊີອື່ນ"
|
||||
},
|
||||
"language_display_name": "ພາສາອັງກິດ",
|
||||
"menu": {
|
||||
"edit": "ແກ້ໄຂ",
|
||||
"delete": "ລຶບ",
|
||||
|
|
|
|||
|
|
@ -261,7 +261,6 @@
|
|||
"not_supported": "အသိပေးချက်ကို မိုဘိုင်းတွင် မရရှိနိုင်သေးပါ",
|
||||
"periodicSync_new_msg_reminder": "သင့်တွင် မက်ဆေ့ချ်အသစ်များ ရှိနိုင်ပါသည်"
|
||||
},
|
||||
"language_display_name": "အင်္ဂလိပ်",
|
||||
"menu": {
|
||||
"start_private_chat": "သုံးစွဲသူနှင့် တိုက်ရိုက်စာပို့ခြင်း",
|
||||
"direct_chat": "တိုက်ရိုက် ခြက်",
|
||||
|
|
|
|||
|
|
@ -190,7 +190,6 @@
|
|||
"done": "Ferdig",
|
||||
"user_kick_and_ban": "Kast ut"
|
||||
},
|
||||
"language_display_name": "Norsk",
|
||||
"global": {
|
||||
"save": "Lagre",
|
||||
"time": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"language_display_name": "انګریزې",
|
||||
"project": {
|
||||
"name": "را ټولیدل",
|
||||
"tag_line": "ډېر ساده وصل شئ"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@
|
|||
"audio_file": "Arquivo de áudio",
|
||||
"video_file": "Arquivo de vídeo"
|
||||
},
|
||||
"language_display_name": "Inglês",
|
||||
"menu": {
|
||||
"start_private_chat": "Bate-papo privado com este usuário",
|
||||
"reply": "Responder",
|
||||
|
|
@ -423,7 +422,6 @@
|
|||
"send_more_files": "Enviar mais arquivos",
|
||||
"quality": "Qualidade",
|
||||
"original": "Original",
|
||||
"content_credentials": "Credenciais do conteúdo",
|
||||
"learn_more": "Saiba mais"
|
||||
},
|
||||
"notification": {
|
||||
|
|
|
|||
|
|
@ -148,7 +148,6 @@
|
|||
"files": "Ficheiros",
|
||||
"quality": "Qualidade",
|
||||
"original": "Original",
|
||||
"content_credentials": "Credenciais do conteúdo",
|
||||
"learn_more": "Saber mais"
|
||||
},
|
||||
"fallbacks": {
|
||||
|
|
@ -157,7 +156,6 @@
|
|||
"video_file": "Ficheiro de vídeo",
|
||||
"original_text": "<texto original>"
|
||||
},
|
||||
"language_display_name": "Inglês",
|
||||
"project": {
|
||||
"name": "Convocar",
|
||||
"tag_line": "Basta ligar"
|
||||
|
|
|
|||
|
|
@ -247,7 +247,6 @@
|
|||
"download_all": "Descărcați tot",
|
||||
"someone": "Cineva"
|
||||
},
|
||||
"language_display_name": "Engleză",
|
||||
"fallbacks": {
|
||||
"download_name": "Descărcați",
|
||||
"original_text": "<original text>",
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@
|
|||
"join_public": "Любой, у кого есть ссылка",
|
||||
"copy_invite_link": "Скопировать ссылку на приглашение",
|
||||
"scan_code": "Сканировать, чтобы присоединиться к комнате",
|
||||
"message_retention": "История сообщений",
|
||||
"message_retention_info": "Сообщения, отправленные в этом временном интервале, могут просматривать все, у кого есть пригласительная ссылка.",
|
||||
"message_retention": "Предел истории",
|
||||
"message_retention_info": "Установить лимит на то, как долго хранить сообщения",
|
||||
"direct_link": "Моя прямая ссылка",
|
||||
"title": "Сведения о комнате",
|
||||
"created_by": "Создано {user}",
|
||||
|
|
@ -51,13 +51,13 @@
|
|||
"user": "{user}",
|
||||
"user_you": "{user} (вы)",
|
||||
"show_all": "Показать все >",
|
||||
"export_room": "Экспорт чата",
|
||||
"export_room": "Сохранить чат",
|
||||
"moderation": "Модерация",
|
||||
"room_type": "Тип комнаты",
|
||||
"voice_mode": "Голосовой режим",
|
||||
"voice_mode_info": "Переключает интерфейс чата в режим \"прослушивания и записи\"",
|
||||
"file_mode": "Файловый режим",
|
||||
"download_chat": "Скачать чат",
|
||||
"download_chat": "Сохранить чат",
|
||||
"read_only_room_info": "Отправлять сообщения в комнате могут только администраторы и модераторы.",
|
||||
"message_retention_4_week": "4 недели",
|
||||
"shared_room_number": "Вы делите {count} комнат с {name}",
|
||||
|
|
@ -71,7 +71,18 @@
|
|||
"report_reason": "Причина",
|
||||
"report": "Сообщить об ошибке",
|
||||
"accept_knock": "Принять",
|
||||
"reject_knock": "Запретить"
|
||||
"reject_knock": "Запретить",
|
||||
"join_knock": "Люди могут ́knock ́.",
|
||||
"knocks": "Носки",
|
||||
"user_creator": "Создатель",
|
||||
"limit_history_info": "Сохранить сообщения для {period}",
|
||||
"message_history_info": "Позволить людям видеть сообщения, отправленные до их присоединения",
|
||||
"report_info": "Сообщите об этой комнате администраторам службы за незаконный, оскорбительный или иным образом вредный контент",
|
||||
"confirm_make_admin": "Вы хотите сделать этого пользователя администратором?",
|
||||
"confirm_revoke_admin": "Вы хотите удалить права администратора от этого пользователя?",
|
||||
"confirm_make_moderator": "Вы хотите сделать этого пользователя модератором?",
|
||||
"confirm_revoke_moderator": "Вы хотите удалить права модератора от этого пользователя?",
|
||||
"confirm_ban": "Вы хотите выбить этого пользователя из комнаты?"
|
||||
},
|
||||
"file_mode": {
|
||||
"sending": "Отправка",
|
||||
|
|
@ -87,7 +98,21 @@
|
|||
"choose_files": "Выбрать файлы",
|
||||
"quality": "Качество",
|
||||
"original": "Оригинал",
|
||||
"learn_more": "Узнать больше"
|
||||
"learn_more": "Узнать больше",
|
||||
"compressed": "сжатый",
|
||||
"metadata_info_compressed": "Сжатие изображения автоматически исключает его метаданные.",
|
||||
"metadata_info_original": "Обмен оригиналом автоматически включает в себя его метаданные.",
|
||||
"exif_data": "Данные",
|
||||
"ai_used": "Фото изменено с ИИ",
|
||||
"screenshot_taken_on": "Скриншот {date}",
|
||||
"captured_with_camera": "Захвачена реальной камерой. ",
|
||||
"old_photo": "Фото старше 3 месяцев. ",
|
||||
"screenshot": "Снимок экрана. ",
|
||||
"captured_screenshot": "Снимок экрана. ",
|
||||
"captured_screenshot_ago": "Скриншот запечатлен {ago} назад. ",
|
||||
"cc_capture_timestamp": "Захват временной метки",
|
||||
"cc_location": "Локация",
|
||||
"cc_source": "Источник"
|
||||
},
|
||||
"global": {
|
||||
"save": "Сохранить",
|
||||
|
|
@ -95,9 +120,9 @@
|
|||
"show_more": "Показать больше",
|
||||
"time": {
|
||||
"recently": "только что",
|
||||
"minutes": "{n} минуту назад | {n} минут назад | {n} минут назад",
|
||||
"hours": "{n} час назад | {n} часов назад | {n} часов назад",
|
||||
"days": "{n} день назад | {n} дней назад | {n} дней назад"
|
||||
"minutes": "1 минуту назад | {n} минут назад",
|
||||
"hours": "1 час назад | {n} часов назад",
|
||||
"days": "1 день назад | {n} дней назад"
|
||||
},
|
||||
"notify": "Оповестить",
|
||||
"password_didnot_match": "Пароли не совпадают",
|
||||
|
|
@ -106,7 +131,8 @@
|
|||
"click_to_remove": "Нажмите, чтобы удалить",
|
||||
"close": "закрыть",
|
||||
"different_browser_title": "Попробуйте другой браузер",
|
||||
"different_browser_content": "Некоторые функции могут работать некорректно. Скопируйте ссылку и откройте её в другом браузере."
|
||||
"different_browser_content": "Некоторые функции могут работать некорректно. Скопируйте ссылку и откройте её в другом браузере.",
|
||||
"retry": "Обновить"
|
||||
},
|
||||
"message": {
|
||||
"download_all": "Скачать все",
|
||||
|
|
@ -167,12 +193,15 @@
|
|||
"replying_to": "В ответ {user}",
|
||||
"your_message": "Ваше сообщение...",
|
||||
"scale_image": "Масштабировать изображение",
|
||||
"time_ago": "Сегодня | Вчера | {count} дней назад | {count} дней назад",
|
||||
"time_ago": "Сегодня | Вчера | {count} дней назад",
|
||||
"not_allowed_to_send": "Только администраторы и модераторы могут отправлять сообщения в комнате",
|
||||
"reaction_count_more": "{reactionCount} больше",
|
||||
"seen_by_count": "Не просмотрено | Просмотрено 1 участником | Просмотрено {count} участниками",
|
||||
"send_attachements_dialog_title": "Вы хотите отправить следующие вложения?",
|
||||
"failed_to_render": "Не удалось обработать событие"
|
||||
"failed_to_render": "Не удалось обработать событие",
|
||||
"room_upgraded": "Эта комната была модернизирована, идите {link}, чтобы присоединиться к обсуждению",
|
||||
"room_upgraded_link": "сюда",
|
||||
"room_upgraded_view_old": "Этот номер был модернизирован. Нажмите {link}, чтобы просмотреть старые сообщения"
|
||||
},
|
||||
"new_room": {
|
||||
"create": "Создать",
|
||||
|
|
@ -182,7 +211,7 @@
|
|||
"name_room": "Название комнаты",
|
||||
"join_permissions_info": "Эти разрешения определяют, как присоединиться к комнате и как пригласить новых участников. Они могут быть изменены в любое время.",
|
||||
"set_join_permissions": "Установка разрешений на присоединение",
|
||||
"room_topic": "Добавьте описание, если хотите",
|
||||
"room_topic": "Добавить описание, если хотите",
|
||||
"join_permissions": "Разрешения на присоединение",
|
||||
"get_link": "Получить ссылку",
|
||||
"add_people": "Добавить людей",
|
||||
|
|
@ -193,8 +222,9 @@
|
|||
"invite_info": "Только добавленные участники",
|
||||
"invite_description": "Выберите из списка или выполните поиск по ID пользователя",
|
||||
"status_creating": "Создание комнаты",
|
||||
"status_avatar": "Загрузка аватара: {count}",
|
||||
"room_name_limit_error_msg": "Не более 50 символов"
|
||||
"status_avatar": "Загрузка аватар",
|
||||
"room_name_limit_error_msg": "Не более 50 символов",
|
||||
"title": "Новый групповой чат"
|
||||
},
|
||||
"room_info_sheet": {
|
||||
"view_details": "Подробнее",
|
||||
|
|
@ -244,13 +274,18 @@
|
|||
"loading": "Загрузка {appName}",
|
||||
"user_revoke_moderator": "Отозвать статус модератора",
|
||||
"user_make_moderator": "Назначить модератором",
|
||||
"user_make_admin": "Назначить администратором"
|
||||
"user_make_admin": "Назначить администратором",
|
||||
"user_revoke_admin": "Отменить права администратора",
|
||||
"pin": "Штифт",
|
||||
"unpin": "Открепить пост",
|
||||
"cancel_knock": "Отменить стук",
|
||||
"upgrade": "Обновление"
|
||||
},
|
||||
"room": {
|
||||
"leave": "Выйти",
|
||||
"room_list_invites": "Приглашения",
|
||||
"room_list_rooms": "Комнаты",
|
||||
"unseen_messages": "У вас нет непросмотренных сообщений | У вас {count} непросмотренное сообщение | У вас {count} непросмотренных сообщений | У вас {count} непросмотренных сообщений",
|
||||
"unseen_messages": "У вас нет непросмотренных сообщений | У вас 1 непросмотренное сообщение | У вас {count} непросмотренных сообщений",
|
||||
"members": "нет участников | 1 участник | {count} участников",
|
||||
"purge_set_room_state": "Установка статуса комнаты",
|
||||
"purge_removing_members": "Удаление участников ({count} из {total})",
|
||||
|
|
@ -259,7 +294,9 @@
|
|||
"room_topic_required": "Описание комнаты обязательно",
|
||||
"invitations": "У вас нет приглашений | У вас 1 приглашение | У вас {count} приглашений",
|
||||
"purge_redacting_events": "Удаление сообщений ({count} из {total})",
|
||||
"room_list_new_messages": "{count} новых сообщений"
|
||||
"room_list_new_messages": "{count} новых сообщений",
|
||||
"needs_upgrade": "Эта комната должна быть модернизирована до новой версии комнаты",
|
||||
"upgrading": "Улучшенная версия комнаты"
|
||||
},
|
||||
"room_welcome": {
|
||||
"got_it": "Понятно!",
|
||||
|
|
@ -277,7 +314,8 @@
|
|||
"info": "Добро пожаловать! Вот несколько вещей, которые нужно знать о вашей комнате:",
|
||||
"direct_private_chat": "Личное сообщение",
|
||||
"info_retention_user": "🕓 Сообщения старше {time} будут удалены из истории.",
|
||||
"info_auto_join": "Добро пожаловать в {room}.\nВы присоединяетесь как {you}."
|
||||
"info_auto_join": "Добро пожаловать в {room}.\nВы присоединяетесь как {you}.",
|
||||
"join_knock": "Люди могут попросить присоединиться к ́nocking ́."
|
||||
},
|
||||
"device_list": {
|
||||
"blocked": "Заблокированный",
|
||||
|
|
@ -313,7 +351,8 @@
|
|||
"language_description": "Convene доступен на многих языках.",
|
||||
"dont_see_yours": "Не видите своего?",
|
||||
"tell_us": "Сообщите нам.",
|
||||
"display_name_required": "Отображаемое имя обязательно"
|
||||
"display_name_required": "Отображаемое имя обязательно",
|
||||
"my_rooms": "Мои комнаты"
|
||||
},
|
||||
"join": {
|
||||
"user_name_label": "Имя пользователя",
|
||||
|
|
@ -331,7 +370,11 @@
|
|||
"choose_name": "Выберите себе имя",
|
||||
"status_logging_in": "Вход…",
|
||||
"knock": "Постучаться",
|
||||
"enter_knock": "Вступить"
|
||||
"enter_knock": "Вступить",
|
||||
"knock_reason": "Причина нока (факультативно)",
|
||||
"status_knocking": "Стучал...",
|
||||
"accept_ua": "Я согласен с {agreement}",
|
||||
"ua": "пользовательское соглашение"
|
||||
},
|
||||
"leave": {
|
||||
"title_invite": "Вы уверены, что хотите выйти?",
|
||||
|
|
@ -388,7 +431,8 @@
|
|||
"identity_temporary": "{displayName}",
|
||||
"want_more": "Хотите больше?",
|
||||
"powered_by": "Эта комната использует {product}. Узнайте больше по ссылке {productLink} или продолжайте, и создайте другую комнату!",
|
||||
"new_room": "Новая комната"
|
||||
"new_room": "Новая комната",
|
||||
"review_ua": "Обзор Соглашения об Пользователе Сервиса"
|
||||
},
|
||||
"invite": {
|
||||
"title": "Добавить друзей",
|
||||
|
|
@ -421,7 +465,6 @@
|
|||
"title": "Получено новое сообщение",
|
||||
"periodicSync_new_msg_reminder": "У вас могут быть новые сообщения"
|
||||
},
|
||||
"language_display_name": "Русский",
|
||||
"project": {
|
||||
"name": "Convene",
|
||||
"tag_line": "Просто подключись"
|
||||
|
|
@ -439,7 +482,9 @@
|
|||
"info": "Транслируйте новости или знания в любом формате – видео, подкаст, текст, картинки или PDF-файлы.",
|
||||
"channel_name": "Дайте название вашему каналу",
|
||||
"channel_topic": "Опишите его",
|
||||
"error_channel": "Не удалось создать канал"
|
||||
"error_channel": "Не удалось создать канал",
|
||||
"channel_topic_label": "Ваше описание будет отображаться, когда люди присоединятся к вашему каналу.",
|
||||
"status_creating": "Создание канала"
|
||||
},
|
||||
"room_export": {
|
||||
"fetched_n_events": "Найдено {count} событий",
|
||||
|
|
@ -450,6 +495,23 @@
|
|||
},
|
||||
"create": {
|
||||
"room_type_channel_name": "Канал",
|
||||
"field_required": "Поле обязательно"
|
||||
"field_required": "Поле обязательно",
|
||||
"title": "Выберите опыт",
|
||||
"room_type_room_name": "Групповой чат",
|
||||
"room_type_room_description": "Подключите группу людей",
|
||||
"room_type_channel_description": "Новости и идеи",
|
||||
"room_type_filedrop_name": "Падение файла",
|
||||
"room_type_filedrop_description": "Получение файлов и советов",
|
||||
"topic_too_long": "Разрешено максимум 500 символов"
|
||||
},
|
||||
"createfiledrop": {
|
||||
"title": "Создание файла Drop",
|
||||
"info": "Файловые капли - это безопасное пространство для получения файлов от любого.",
|
||||
"filedrop_name": "Имя папка файла",
|
||||
"error_filedrop": "Не удалось создать падение файла",
|
||||
"status_creating": "Создание папки файлов"
|
||||
},
|
||||
"cc": {
|
||||
"metadata-stripped": "Изображение было сжато и лишено метаданных.\n\nМы считаем, что это изображение имеет дополнительную информацию о файле. Если вы хотите узнать больше, попросите отправителя поделиться оригиналом."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
"delete": "මකන්න",
|
||||
"done": "අහවරයි"
|
||||
},
|
||||
"language_display_name": "ඉංග්රීසි",
|
||||
"message": {
|
||||
"download_progress": "{percentage}% බාගත වී ඇත",
|
||||
"file_prefix": "ගොනුව: ",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{
|
||||
"language_display_name": "İngilizce",
|
||||
"global": {
|
||||
"save": "Kaydet",
|
||||
"show_less": "Ayrıntıları gizle",
|
||||
|
|
@ -17,7 +16,8 @@
|
|||
"add_reaction": "Tepki ekle",
|
||||
"click_to_remove": "Kaldırmak için tıklayın",
|
||||
"different_browser_title": "Başka bir tarayıcı kullan",
|
||||
"different_browser_content": "Bazı özellikler çalışmayabilir. Bağlantıyı kopyalaıp başka bir tarayıcıda aç."
|
||||
"different_browser_content": "Bazı özellikler çalışmayabilir. Bağlantıyı kopyalaıp başka bir tarayıcıda aç.",
|
||||
"retry": "Yeniden dene"
|
||||
},
|
||||
"menu": {
|
||||
"logout": "Oturumu kapat",
|
||||
|
|
@ -42,7 +42,8 @@
|
|||
"loading": "{appName} yükleniyor",
|
||||
"user_make_admin": "Yönetici yap",
|
||||
"user_make_moderator": "Sorumlu yap",
|
||||
"user_revoke_moderator": "Sorumluluğu kaldır"
|
||||
"user_revoke_moderator": "Sorumluluğu kaldır",
|
||||
"upgrade": "Yükselt"
|
||||
},
|
||||
"message": {
|
||||
"file": "Dosya",
|
||||
|
|
@ -108,7 +109,8 @@
|
|||
"reaction_count_more": "{reactionCount} diğer",
|
||||
"send_attachements_dialog_title": "Şu eklentileri göndermek istiyor musunuz?",
|
||||
"download_all": "Hepsini indir",
|
||||
"failed_to_render": "Eylem oluşturulamadı"
|
||||
"failed_to_render": "Eylem oluşturulamadı",
|
||||
"room_upgraded_link": "buradan"
|
||||
},
|
||||
"login": {
|
||||
"accept_terms": "Kabul et",
|
||||
|
|
@ -177,7 +179,9 @@
|
|||
"files_sent_with_note": "1 dosya not ile birlikte gönderildi! | {count} dosya not ile birlikte gönderildi!",
|
||||
"quality": "Kodlama",
|
||||
"original": "Orijinal",
|
||||
"learn_more": "Daha fazla bilgi edin"
|
||||
"learn_more": "Daha fazla bilgi edin",
|
||||
"cc_source": "Kaynak",
|
||||
"cc_location": "Konum"
|
||||
},
|
||||
"room": {
|
||||
"leave": "Ayrıl",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@
|
|||
"file": "ھۆججەت",
|
||||
"files": "ھۆججەت"
|
||||
},
|
||||
"language_display_name": "ئۇيغۇرچە",
|
||||
"new_room": {
|
||||
"link_copied": "ئۇلىنىش كۆچۈرۈلدى!",
|
||||
"status_avatar": "باش سۈرىتى يۈكلىنىۋاتىدۇ: {count}",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
"click_to_remove": "Натисніть, щоб видалити",
|
||||
"show_less": "Показувати менше",
|
||||
"show_more": "Показати більше",
|
||||
"notify": "Повідомити"
|
||||
"notify": "Повідомити",
|
||||
"retry": "Повторити спробу"
|
||||
},
|
||||
"menu": {
|
||||
"start_private_chat": "Пряме повідомлення з цим користувачем",
|
||||
|
|
@ -48,7 +49,6 @@
|
|||
"user_revoke_admin": "Відкинути адміністратора",
|
||||
"upgrade": "Оновлення"
|
||||
},
|
||||
"language_display_name": "Англійська",
|
||||
"project": {
|
||||
"name": "Скликати",
|
||||
"tag_line": ",Просто підключіться"
|
||||
|
|
@ -120,7 +120,8 @@
|
|||
"download_all": "Завантажити усі",
|
||||
"room_upgraded": "Цю кімнату оновлено, перейдіть за посиланням {link}, щоб знову приєднатися до обговорення",
|
||||
"room_upgraded_link": "тут",
|
||||
"room_upgraded_view_old": "Цю кімнату оновлено. Натисніть {link}, щоб переглянути старі повідомлення"
|
||||
"room_upgraded_view_old": "Цю кімнату оновлено. Натисніть {link}, щоб переглянути старі повідомлення",
|
||||
"download_media": "Завантажити медіафайли"
|
||||
},
|
||||
"room": {
|
||||
"invitations": "У вас немає запрошень | У вас є 1 запрошення | У вас є {count} запрошень",
|
||||
|
|
@ -313,7 +314,11 @@
|
|||
"leave": "Залишити"
|
||||
},
|
||||
"logout": {
|
||||
"confirm_text": "Ви впевнені, що хочете вийти?"
|
||||
"confirm_text": "Ви впевнені, що хочете вийти?",
|
||||
"copy_credentials": "Скопіювати ім'я користувача та пароль",
|
||||
"copied_credentials": "Скопійовано ім'я користувача та пароль",
|
||||
"copied_credentials_value": "Ім'я користувача: {userId} \nПароль: {password}",
|
||||
"copy_credentials_desc": "Для відновлення доступу до чатів з нового пристрою або браузера потрібні ваші ім’я користувача та пароль. Рекомендуємо зберігати ці облікові дані в безпечному місці."
|
||||
},
|
||||
"purge_room": {
|
||||
"title": "Видалити кімнату?",
|
||||
|
|
@ -487,7 +492,6 @@
|
|||
"files": "Файли",
|
||||
"quality": "Якість",
|
||||
"original": "Оригінальний",
|
||||
"content_credentials": "Облікові дані вмісту",
|
||||
"learn_more": "Дізнатися більше",
|
||||
"choose_files": "Вибрати файли",
|
||||
"any_file_format_accepted": "Приймається будь-який формат файлу",
|
||||
|
|
@ -501,10 +505,34 @@
|
|||
"metadata_info_compressed": "Стиснення зображення автоматично виключає його метадані.",
|
||||
"metadata_info_original": "Спільний доступ до оригіналу автоматично включає його метадані.",
|
||||
"exif_data": "Дані Exif",
|
||||
"content_credentials_info": "Для цього медіа доступна інформація про джерело або історію, яку потрібно перевірити.",
|
||||
"ai_used": "Фото змінено за допомогою штучного інтелекту",
|
||||
"screenshot_taken_on": "Знімок екрана зроблено {date}",
|
||||
"captured_with_camera": "Знято камерою",
|
||||
"old_photo": "Фотографія старша за 3 місяці"
|
||||
"captured_with_camera": "Знято справжньою камерою. ",
|
||||
"old_photo": "Фото старше 3 місяців. ",
|
||||
"screenshot": "Знімок екрана. ",
|
||||
"captured_screenshot": "Знімок екрана. ",
|
||||
"captured_screenshot_ago": "Знімок екрана зроблено {ago} тому. ",
|
||||
"cc_source": "Джерело",
|
||||
"cc_capture_timestamp": "Позначка часу захоплення",
|
||||
"cc_location": "Розташування"
|
||||
},
|
||||
"cc": {
|
||||
"metadata-stripped": "Зображення стиснуто, а його метадані видалено. Якщо вам потрібно більше контексту, попросіть відправника надати оригінальний файл.",
|
||||
"details": "Деталі",
|
||||
"cc_info": "Більше інформації доступно. Завантажте зображення за посиланням {link}",
|
||||
"cc_info_link": "Перевірка облікових даних контенту",
|
||||
"cc_no_info": "Деталі цього зображення неможливо перевірити.",
|
||||
"download_image": "Завантажити зображення",
|
||||
"contains_modified_and_ai_generated": "Містить модифіковані та згенеровані штучним інтелектом медіафайли.",
|
||||
"take_a_closer_look": "Придивіться уважніше.",
|
||||
"contains_modified": "Містить модифіковані носії.",
|
||||
"contains_ai_generated": "Містить медіафайли, згенеровані штучним інтелектом.",
|
||||
"information_available": "Інформація доступна для поширення у ЗМІ.",
|
||||
"captured_with_camera": "Знято камерою.",
|
||||
"screenshot": "Знімок екрана.",
|
||||
"screenshot_probably": "Зображення виглядає як скріншот.",
|
||||
"generated_with_ai": "Згенеровано за допомогою штучного інтелекту.",
|
||||
"modified": "Змінено.",
|
||||
"older_than_n_months": "Старше ніж {n} місяців."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,7 +193,8 @@
|
|||
"sent_media": "已发送{count} 媒体项目。",
|
||||
"room_upgraded": "此聊天室已升级,请前往{link}重新加入讨论",
|
||||
"room_upgraded_view_old": "此聊天室已升级,点击{link}查看旧信息",
|
||||
"room_upgraded_link": "这里"
|
||||
"room_upgraded_link": "这里",
|
||||
"download_media": "下载媒体"
|
||||
},
|
||||
"menu": {
|
||||
"login": "登录",
|
||||
|
|
@ -359,7 +360,6 @@
|
|||
"close_tab": "关闭浏览器标签",
|
||||
"room_deleted": "聊天室已删除。"
|
||||
},
|
||||
"language_display_name": "简体中文",
|
||||
"global": {
|
||||
"save": "保存",
|
||||
"password_didnot_match": "密码不匹配",
|
||||
|
|
@ -377,10 +377,15 @@
|
|||
"close": "关闭",
|
||||
"notify": "通知",
|
||||
"different_browser_title": "尝试其他的浏览器",
|
||||
"different_browser_content": "某些功能可能会中断。复制链接后用另一个浏览器打开。"
|
||||
"different_browser_content": "某些功能可能会中断。复制链接后用另一个浏览器打开。",
|
||||
"retry": "重试"
|
||||
},
|
||||
"logout": {
|
||||
"confirm_text": "您确定要注销吗?"
|
||||
"confirm_text": "您确定要注销吗?",
|
||||
"copy_credentials": "复制用户名和密码",
|
||||
"copied_credentials": "用户名和密码已复制",
|
||||
"copied_credentials_value": "用户名: {userId} \n\n密码: {password}",
|
||||
"copy_credentials_desc": "您需要使用用户名和密码才能在新的设备或浏览器上重新访问您的聊天记录。我们建议您将这些凭据保存在安全的地方。"
|
||||
},
|
||||
"poll_create": {
|
||||
"title": "创建新投票",
|
||||
|
|
@ -430,17 +435,21 @@
|
|||
"files_sent": "已发送 1 个文件! | 已发送 {count} 个文件!",
|
||||
"quality": "画质",
|
||||
"original": "原来的",
|
||||
"content_credentials": "内容凭据",
|
||||
"learn_more": "了解更多",
|
||||
"compressed": "压缩",
|
||||
"metadata_info_compressed": "压缩图像会自动排除其元数据。",
|
||||
"metadata_info_original": "分享原件将自动包含其元数据。",
|
||||
"exif_data": "Exif 数据",
|
||||
"content_credentials_info": "该媒体可提供来源或历史信息以供验证。",
|
||||
"ai_used": "经过 AI 修改的照片",
|
||||
"screenshot_taken_on": "屏幕截图拍摄于 {date}",
|
||||
"captured_with_camera": "用相机拍摄",
|
||||
"old_photo": "3 个月前的照片"
|
||||
"captured_with_camera": "用真正的相机拍摄. ",
|
||||
"old_photo": "3 个月以前的照片 ",
|
||||
"screenshot": "屏幕截图. ",
|
||||
"captured_screenshot": "屏幕截图. ",
|
||||
"captured_screenshot_ago": "屏幕截图于{ago}前截取 ",
|
||||
"cc_source": "来源",
|
||||
"cc_capture_timestamp": "捕获时间戳",
|
||||
"cc_location": "地点"
|
||||
},
|
||||
"notification": {
|
||||
"dialog": {
|
||||
|
|
@ -506,5 +515,24 @@
|
|||
"filedrop_name": "命名您的文件投递",
|
||||
"error_filedrop": "无法创建文件投递",
|
||||
"status_creating": "正在创建文件投递"
|
||||
},
|
||||
"cc": {
|
||||
"details": "细节",
|
||||
"cc_info": "更多信息可供查阅。请将图片上传至 {link}",
|
||||
"cc_info_link": "内容凭据验证",
|
||||
"cc_no_info": "此图片的详细信息无法验证。",
|
||||
"metadata-stripped": "图片已被压缩,且元数据已被移除。如果您需要更多背景信息,请向发件人索要原始文件。",
|
||||
"download_image": "下载图片",
|
||||
"contains_modified_and_ai_generated": "包含经过修改和人工智能生成的媒体内容。",
|
||||
"take_a_closer_look": "仔细查看一下。",
|
||||
"contains_modified": "包含修改过的媒体。",
|
||||
"contains_ai_generated": "包含人工智能生成的媒体内容。",
|
||||
"information_available": "可获取已分享的媒体信息。",
|
||||
"captured_with_camera": "用相机拍摄。",
|
||||
"screenshot": "屏幕截图。",
|
||||
"screenshot_probably": "这张图片看起来像是一张屏幕截图。",
|
||||
"generated_with_ai": "由人工智能生成。",
|
||||
"modified": "已修改。",
|
||||
"older_than_n_months": "超过 {n} 个月。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
no-gutters
|
||||
align-content="center"
|
||||
>
|
||||
<v-col cols="auto" class="me-2 align-self-center">
|
||||
<v-col cols="auto" class="me-2 align-self-center" v-if="icon != null && icon != ''">
|
||||
<v-icon :size="iconSize">{{ icon }}</v-icon>
|
||||
</v-col>
|
||||
<v-col class="align-self-center">{{ text }}</v-col>
|
||||
|
|
|
|||
|
|
@ -13,20 +13,22 @@
|
|||
</v-fade-transition>
|
||||
<div
|
||||
class="bottom-sheet-content"
|
||||
:data-state="isMove ? 'move' : state"
|
||||
:data-state="state"
|
||||
ref="pan"
|
||||
:style="{ top: `${isMove ? y : calcY()}px` }"
|
||||
>
|
||||
<v-container>
|
||||
<v-row justify="end">
|
||||
<v-col cols="1" class="text-right">
|
||||
<v-btn
|
||||
size="small"
|
||||
elevation="0"
|
||||
@click.stop="onBackgroundClick"
|
||||
class="bottom-sheet-close"
|
||||
v-if="showCloseButton"
|
||||
icon="cancel"
|
||||
icon="close"
|
||||
>
|
||||
</v-btn>
|
||||
<div class="bottom-sheet-handle"><div class="bottom-sheet-handle-decoration" /></div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<div ref="sheetContent" class="sheetContent">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
|
@ -35,102 +37,13 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Hammer from "hammerjs";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
showCloseButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
openY: {
|
||||
type: Number,
|
||||
default: 0.1,
|
||||
},
|
||||
halfY: {
|
||||
type: Number,
|
||||
default: 0.5,
|
||||
},
|
||||
defaultState: {
|
||||
type: String,
|
||||
default: "closed",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mc: null,
|
||||
y: 0,
|
||||
startY: 0,
|
||||
startYCoord: 0,
|
||||
isMove: false,
|
||||
state: this.defaultState,
|
||||
rect: {},
|
||||
state: "closed"
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
window.onresize = () => {
|
||||
this.rect = this.$refs.sheet.getBoundingClientRect();
|
||||
};
|
||||
this.rect = this.$refs.sheet.getBoundingClientRect();
|
||||
|
||||
this.mc = new Hammer(this.$refs.pan);
|
||||
this.mc.get("pan").set({ direction: Hammer.DIRECTION_ALL });
|
||||
|
||||
const self = this;
|
||||
|
||||
this.mc.on("panup pandown", (evt) => {
|
||||
self.y = self.startYCoord - (self.startY - evt.center.y);
|
||||
});
|
||||
|
||||
this.mc.on("panstart", (evt) => {
|
||||
self.startY = evt.center.y;
|
||||
self.startYCoord = this.calcY();
|
||||
self.isMove = true;
|
||||
});
|
||||
|
||||
this.mc.on("pancancel", (ignoredEvt) => {
|
||||
self.y = self.startYCoord;
|
||||
self.isMove = false;
|
||||
});
|
||||
|
||||
this.mc.on("panend", (evt) => {
|
||||
self.isMove = false;
|
||||
|
||||
switch (self.state) {
|
||||
case "small":
|
||||
if (self.startY - evt.center.y > 120) {
|
||||
self.state = "open";
|
||||
}
|
||||
|
||||
if (self.startY - evt.center.y < -50) {
|
||||
self.state = "closed";
|
||||
}
|
||||
break;
|
||||
case "open":
|
||||
if (self.startY - evt.center.y < -120) {
|
||||
self.state = "small";
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.mc.destroy();
|
||||
window.onresize = null;
|
||||
},
|
||||
methods: {
|
||||
calcY() {
|
||||
switch (this.state) {
|
||||
case "closed":
|
||||
return this.rect.height;
|
||||
case "open":
|
||||
return this.rect.height * this.openY;
|
||||
case "small":
|
||||
return this.rect.height * this.halfY;
|
||||
default:
|
||||
return this.y;
|
||||
}
|
||||
},
|
||||
open() {
|
||||
this.setState("small");
|
||||
},
|
||||
|
|
@ -167,11 +80,13 @@ export default {
|
|||
|
||||
.sheetContent {
|
||||
position:absolute;
|
||||
top:20px;left:0;
|
||||
top: 60px;
|
||||
padding: 0 20px 20px 20px;
|
||||
left:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
overflow-y:auto;
|
||||
padding:20px;
|
||||
border-top: 1px solid #E1E1E1;
|
||||
}
|
||||
|
||||
.bottom-sheet {
|
||||
|
|
@ -194,23 +109,9 @@ export default {
|
|||
background-color: rgba(black, 0.4);
|
||||
}
|
||||
|
||||
.bottom-sheet-handle {
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
position: relative;
|
||||
.bottom-sheet-handle-decoration {
|
||||
background-color: #cccccc;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-sheet-content {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
top: 100px;
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
|
@ -218,11 +119,8 @@ export default {
|
|||
background-color: white;
|
||||
overflow: hidden;
|
||||
|
||||
.bottom-sheet-close {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
z-index: 1;
|
||||
&[data-state="small"], &[data-state="open"], &[data-state="closed"] {
|
||||
transition: top 0.3s ease-out;
|
||||
}
|
||||
|
||||
@media #{map.get($display-breakpoints, 'lg-and-up')} {
|
||||
|
|
@ -230,16 +128,4 @@ export default {
|
|||
width: $dialog-desktop-width;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-sheet-content[data-state="small"],
|
||||
.bottom-sheet-content[data-state="open"],
|
||||
.bottom-sheet-content[data-state="closed"] {
|
||||
transition: top 0.3s ease-out;
|
||||
}
|
||||
|
||||
.bottom-sheet-content[data-state="small"] {
|
||||
@media #{map.get($display-breakpoints, 'lg-and-up')} {
|
||||
top: 100px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
v-on:add-files="(files) => addAttachments(files)"
|
||||
:batch="uploadBatch"
|
||||
v-on:close="closeFileMode"
|
||||
v-on:reset="resetFileMode"
|
||||
:fileModeMode="true"
|
||||
:defaultRootMessageText="$t('file_mode.files')"
|
||||
/>
|
||||
|
|
@ -54,8 +55,9 @@
|
|||
v-on:addquickreaction="addQuickReaction"
|
||||
v-on:addreply="addReply(selectedEvent)"
|
||||
v-on:edit="edit(selectedEvent)"
|
||||
v-on:redact="redact(selectedEvent)"
|
||||
v-on:redact="showDeletePostPopup = true"
|
||||
v-on:download="download(selectedEvent)"
|
||||
v-on:report="reportEvent(selectedEvent)"
|
||||
v-on:more="
|
||||
isEmojiQuickReaction=true;
|
||||
showMoreMessageOperations({event: selectedEvent, anchor: $event.anchor})
|
||||
|
|
@ -244,7 +246,7 @@
|
|||
:title="room.name"
|
||||
/>
|
||||
|
||||
<BottomSheet ref="messageOperationsSheet" halfY="0.1">
|
||||
<BottomSheet ref="messageOperationsSheet">
|
||||
<EmojiPicker ref="emojiPicker"
|
||||
:native="true"
|
||||
@select="emojiSelected"
|
||||
|
|
@ -296,11 +298,15 @@
|
|||
<PurgeRoomDialog v-model="showPurgeConfirmation" :room="room" />
|
||||
|
||||
<RoomExport :room="room" v-if="downloadingChat" v-on:close="downloadingChat = false" />
|
||||
<ReportRoomOrEventDialog v-model="reportingEventShown" :room="room" :eventId="reportingEventId" />
|
||||
|
||||
<!-- Heart animation -->
|
||||
<div :class="['heart-wrapper', { 'is-active': heartAnimation }]" :style="hearAnimationPosition">
|
||||
<div :class="['heart', { 'is-active': heartAnimation }]" />
|
||||
</div>
|
||||
|
||||
<!-- Delete post dialog -->
|
||||
<DeletePostDialog v-model="showDeletePostPopup" v-on:deletePost="onDeletePost"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -323,6 +329,7 @@ import UserProfileDialog from "./UserProfileDialog.vue"
|
|||
import RoomUpgradePrompt from "./messages/composition/RoomUpgradePrompt.vue";
|
||||
import BottomSheet from "./BottomSheet.vue";
|
||||
import CreatePollDialog from "./CreatePollDialog.vue";
|
||||
import ReportRoomOrEventDialog from "./ReportRoomOrEventDialog.vue";
|
||||
import chatMixin, { ROOM_READ_MARKER_EVENT_PLACEHOLDER } from "./chatMixin";
|
||||
import AudioLayout from "./AudioLayout.vue";
|
||||
import SendAttachmentsLayout from "./file_mode/SendAttachmentsLayout.vue";
|
||||
|
|
@ -333,6 +340,7 @@ import MessageErrorHandler from "./MessageErrorHandler";
|
|||
import MessageOperationsChannel from './messages/channel/MessageOperationsChannel.vue';
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import RoomExport from "./RoomExport.vue";
|
||||
import DeletePostDialog from "./DeletePostDialog.vue"
|
||||
import EmojiPicker from 'vue3-emoji-picker';
|
||||
import 'vue3-emoji-picker/css';
|
||||
import emitter from 'tiny-emitter/instance';
|
||||
|
|
@ -393,7 +401,9 @@ export default {
|
|||
MessageOperationsChannel,
|
||||
RoomExport,
|
||||
EmojiPicker,
|
||||
RoomUpgradePrompt
|
||||
RoomUpgradePrompt,
|
||||
ReportRoomOrEventDialog,
|
||||
DeletePostDialog
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
@ -484,7 +494,9 @@ export default {
|
|||
left: 0
|
||||
},
|
||||
reverseOrder: false,
|
||||
downloadingChat: false
|
||||
downloadingChat: false,
|
||||
reportingEventId: null,
|
||||
showDeletePostPopup: false,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -800,6 +812,16 @@ export default {
|
|||
'--top': this.heartPosition.top,
|
||||
'--left': this.heartPosition.left
|
||||
};
|
||||
},
|
||||
reportingEventShown: {
|
||||
get() {
|
||||
return this.reportingEventId != null;
|
||||
},
|
||||
set(newValue) {
|
||||
if (!newValue) {
|
||||
this.reportingEventId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1661,6 +1683,10 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
reportEvent(event) {
|
||||
this.reportingEventId = event.getId();
|
||||
},
|
||||
|
||||
pin(event) {
|
||||
const eventToPin = event.parentThread ? event.parentThread : event;
|
||||
this.$matrix.setEventPinned(this.room, eventToPin, true);
|
||||
|
|
@ -1975,6 +2001,10 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
resetFileMode() {
|
||||
this.uploadBatch = this.$matrix.attachmentManager.createUpload(this.room);
|
||||
},
|
||||
|
||||
closeFileMode() {
|
||||
this.uploadBatch?.cancel();
|
||||
this.uploadBatch = undefined;
|
||||
|
|
@ -1982,6 +2012,11 @@ export default {
|
|||
.catch((err) => {
|
||||
console.log("Error leaving", err);
|
||||
});
|
||||
},
|
||||
|
||||
onDeletePost() {
|
||||
this.redact(this.selectedEvent);
|
||||
this.showDeletePostPopup = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@
|
|||
<interactive-auth ref="interactiveAuth" />
|
||||
|
||||
<input id="user-avatar-picker" ref="useravatar" type="file" name="user-avatar"
|
||||
@change="handlePickedUserAvatar($event)" accept="image/*" class="d-none" />
|
||||
@change="handlePickedUserAvatar($event)" :accept="supportedAvatarImageTypes" class="d-none" />
|
||||
|
||||
<v-dialog v-model="enterRoomDialog" :width="$vuetify.display.smAndUp ? '50%' : '90%'">
|
||||
<v-card>
|
||||
|
|
@ -268,7 +268,8 @@ export default {
|
|||
this.selectedProfile.image,
|
||||
function (progress) {
|
||||
console.log("Progress: " + JSON.stringify(progress));
|
||||
}
|
||||
},
|
||||
!this.selectedProfile.imageSelectedByUser
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -377,6 +378,7 @@ export default {
|
|||
handlePickedUserAvatar(event) {
|
||||
util.loadAvatarFromFile(event, (image) => {
|
||||
this.selectedProfile.image = image;
|
||||
this.selectedProfile.imageSelectedByUser = true;
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
23
src/components/DeletePostDialog.vue
Normal file
23
src/components/DeletePostDialog.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<v-dialog v-model="showDeletePostPopup" class="ma-0 pa-0" :width="$vuetify.display.smAndUp ? '688px' : '95%'" scroll-strategy="none">
|
||||
<div class="dialog-content text-center">
|
||||
<h2 class="dialog-title">{{ $t("delete_post.confirm_text") }}</h2>
|
||||
<div class="dialog-text">{{ $t("delete_post.confirm_text_desc") }}</div>
|
||||
<v-container fluid>
|
||||
<v-row cols="12">
|
||||
<v-col cols="6">
|
||||
<v-btn variant="flat" block class="text-button" @click.stop="showDeletePostPopup = false">{{
|
||||
$t("menu.cancel") }}</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6" align="center">
|
||||
<v-btn color="red" variant="flat" block class="filled-button" @click="$emit('deletePost')">{{ $t("menu.delete")
|
||||
}}</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
const showDeletePostPopup = defineModel();
|
||||
</script>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<v-img class="image-with-progress" v-bind="{...$props, ...$attrs}">
|
||||
<resize-observer @notify="handleResize" />
|
||||
<v-img ref="image" class="image-with-progress" v-bind="{...$props, ...$attrs}" v-on:load="loaded">
|
||||
<LoadProgress class="image-with-progress__progress" v-if="loadingProgress != undefined && loadingProgress >= 0 && loadingProgress < 100" :percentage="loadingProgress" />
|
||||
</v-img>
|
||||
</template>
|
||||
|
|
@ -12,10 +13,10 @@ import * as sdk from "matrix-js-sdk";
|
|||
import logoMixin from "./logoMixin";
|
||||
import LoadProgress from "./LoadProgress.vue";
|
||||
import { VImg } from "vuetify/components/VImg";
|
||||
import { emit } from "process";
|
||||
|
||||
export default {
|
||||
name: "ImageWithProgress",
|
||||
extends: VImg,
|
||||
components: { LoadProgress },
|
||||
props: {
|
||||
loadingProgress: {
|
||||
|
|
@ -28,6 +29,14 @@ export default {
|
|||
data() {
|
||||
return {};
|
||||
},
|
||||
methods: {
|
||||
loaded() {
|
||||
this.$emit('loaded', this.$refs.image);
|
||||
},
|
||||
handleResize() {
|
||||
this.loaded();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
</div>
|
||||
|
||||
<input id="room-avatar-picker" ref="avatar" type="file" name="avatar" @change="handlePickedAvatar($event)"
|
||||
accept="image/*" class="d-none" />
|
||||
:accept="supportedAvatarImageTypes" class="d-none" />
|
||||
|
||||
<div class="join-lang">
|
||||
<h3 class="mb-2">{{ $t("profile.select_language") }}</h3>
|
||||
|
|
@ -412,7 +412,7 @@ export default {
|
|||
console.log("Join: Updating avatar");
|
||||
return util.setAvatar(this.$matrix, this.selectedProfile.image, function (progress) {
|
||||
console.log("Progress: " + JSON.stringify(progress));
|
||||
});
|
||||
}, !this.selectedProfile.imageSelectedByUser);
|
||||
}
|
||||
}.bind(this)
|
||||
)
|
||||
|
|
@ -480,6 +480,7 @@ export default {
|
|||
handlePickedAvatar(event) {
|
||||
util.loadAvatarFromFile(event, (image) => {
|
||||
this.selectedProfile.image = image;
|
||||
this.selectedProfile.imageSelectedByUser = true;
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,22 @@
|
|||
@click:outside="$emit('onOutsideLogoutPopupClicked')">
|
||||
<div class="dialog-content text-center">
|
||||
<h2 class="dialog-title">{{ $t("logout.confirm_text") }}</h2>
|
||||
<div class="dialog-text">{{ $t("logout.copy_credentials_desc") }}</div>
|
||||
<v-row>
|
||||
<v-col sm="12" md="6" offset-md="3" class="d-flex justify-center">
|
||||
<v-btn
|
||||
ref="copyCredentialsBtn"
|
||||
:color="credentialsCopied ? '#DEE6FF' : 'grey-lighten-4'"
|
||||
class="text-none mb-2 mt-2 pa-5 d-flex justify-space-between"
|
||||
append-icon="$vuetify.icons.ic_copy"
|
||||
@click.stop="onCopyCredentials">
|
||||
{{ $t(`logout.${credentialsCopied?'copied':'copy'}_credentials`)}}
|
||||
<template v-slot:append>
|
||||
<v-icon color="black"></v-icon>
|
||||
</template>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-container fluid>
|
||||
<v-row cols="12">
|
||||
<v-col cols="6">
|
||||
|
|
@ -21,6 +37,7 @@
|
|||
</template>
|
||||
<script>
|
||||
import profileInfoMixin from "./profileInfoMixin";
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
export default {
|
||||
name: "LogoutRoomDialog",
|
||||
|
|
@ -31,5 +48,35 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
credentialsCopied: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState([
|
||||
'auth'
|
||||
]),
|
||||
credentials() {
|
||||
return this.$t(`logout.copied_credentials_value`, { userId: this.auth.user.user_id, password: this.auth.user.password})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onCopyCredentials() {
|
||||
if(this.credentialsCopied) return
|
||||
const self = this;
|
||||
this.$copyText(this.credentials, this.$refs.copyCredentialsBtn.$el).then(
|
||||
function (ignored) {
|
||||
self.credentialsCopied = true;
|
||||
setInterval(() => {
|
||||
self.credentialsCopied = false;
|
||||
}, 3000);
|
||||
},
|
||||
function (e) {
|
||||
console.log(e);
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
<v-dialog v-model="showDialog" content-class="more-menu-popup" class="ma-0 pa-0">
|
||||
<div class="popup-wrapper">
|
||||
<v-card variant="flat">
|
||||
<v-card-text>
|
||||
|
||||
<v-container class="mt-0 pa-0 pt-3 pb-3 action-row-container-no-dividers">
|
||||
<v-card-text class="ma-0 pa-2">
|
||||
<v-container class="mt-0 pa-0 action-row-container-no-dividers">
|
||||
<ActionRow v-for="item in menuItems" :key="item.name" :icon="item.icon" :iconSize="16" :text="item.text" @click="$emit('close');item.handler()" />
|
||||
|
||||
<v-row v-if="showProfile" class="profile-row clickable" @click="viewProfile" no-gutters align-content="center">
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
type="file"
|
||||
name="avatar"
|
||||
@change="handlePickedAvatar($event)"
|
||||
accept="image/*"
|
||||
:accept="supportedAvatarImageTypes"
|
||||
class="d-none"
|
||||
/>
|
||||
</v-avatar>
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
class="ma-0 pa-0"
|
||||
:width="$vuetify.display.smAndUp ? '688px' : '95%'"
|
||||
scroll-strategy="none"
|
||||
>
|
||||
<div class="dialog-content text-center">
|
||||
<h2 class="dialog-title">{{ $t("room_info.report") }}</h2>
|
||||
<div class="dialog-text">{{ $t("room_info.report_info") }}</div>
|
||||
<v-text-field v-model="reason" :label="$t('room_info.report_reason')"></v-text-field>
|
||||
<v-container fluid>
|
||||
<v-row cols="12">
|
||||
<v-col cols="6">
|
||||
<v-btn
|
||||
id="btn-back"
|
||||
variant="flat"
|
||||
block
|
||||
class="text-button"
|
||||
@click="showDialog = false"
|
||||
>{{ $t("menu.cancel") }}</v-btn
|
||||
>
|
||||
</v-col>
|
||||
<v-col cols="6" align="center">
|
||||
<v-btn
|
||||
id="btn-report"
|
||||
color="red"
|
||||
variant="flat"
|
||||
block
|
||||
class="filled-button"
|
||||
@click.stop="onReport()"
|
||||
>{{ $t("room_info.report") }}</v-btn
|
||||
>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</template>
|
||||
<script>
|
||||
import RoomDialogBase from "./RoomDialogBase.vue";
|
||||
|
||||
export default {
|
||||
name: "ReportRoomDialog",
|
||||
extends: RoomDialogBase,
|
||||
data() {
|
||||
return {
|
||||
reason: ""
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onReport() {
|
||||
const events = this.room.getLiveTimeline().getEvents();
|
||||
if (events && events.length > 0) {
|
||||
const eventId = events[events.length - 1].getId();
|
||||
// const path = utils.encodeUri("/rooms/$roomId/report", {
|
||||
// $roomId: this.room.roomId,
|
||||
// });
|
||||
// this.$matrix.matrixClient.http.authedRequest("POST", path, undefined, { reason: this.reason }, { prefix: "/_matrix/client/v3"})
|
||||
this.$matrix.matrixClient.reportEvent(this.room.roomId, eventId, -100, this.reason)
|
||||
.then(() => {
|
||||
this.showDialog = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error reporting", err);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
</style>
|
||||
67
src/components/ReportRoomOrEventDialog.vue
Normal file
67
src/components/ReportRoomOrEventDialog.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<v-dialog v-model="showDialog" class="ma-0 pa-0" :width="$vuetify.display.smAndUp ? '688px' : '95%'"
|
||||
scroll-strategy="none">
|
||||
<div class="dialog-content text-center">
|
||||
<h2 class="dialog-title">{{ $t("room_info.report") }}</h2>
|
||||
<div class="dialog-text">{{ (eventId == null) ? $t("room_info.report_info") : $t("room_info.report_event_info") }}
|
||||
</div>
|
||||
<v-text-field v-model="reason" :label="$t('room_info.report_reason')"></v-text-field>
|
||||
<v-container fluid>
|
||||
<v-row cols="12">
|
||||
<v-col cols="6">
|
||||
<v-btn id="btn-back" variant="flat" block class="text-button" @click="showDialog = false">{{
|
||||
$t("menu.cancel") }}</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6" align="center">
|
||||
<v-btn id="btn-report" color="red" variant="flat" block class="filled-button" @click.stop="onReport()">{{
|
||||
$t("room_info.report") }}</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</template>
|
||||
<script>
|
||||
import RoomDialogBase from "./RoomDialogBase.vue";
|
||||
|
||||
export default {
|
||||
name: "ReportRoomOrEventDialog",
|
||||
extends: RoomDialogBase,
|
||||
props: {
|
||||
// If eventId == null then report Room, otherwise report this event
|
||||
eventId: {
|
||||
type: String,
|
||||
default: function () {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
reason: ""
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
onReport() {
|
||||
let promise = undefined;
|
||||
if (this.eventId) {
|
||||
promise = this.$matrix.matrixClient.reportEvent(this.room.roomId, this.eventId, -100, this.reason)
|
||||
}
|
||||
else {
|
||||
promise = this.$matrix.matrixClient.reportRoom(this.room.roomId, this.reason);
|
||||
}
|
||||
promise.then(() => {
|
||||
this.showDialog = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error reporting", err);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
</style>
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
type="file"
|
||||
name="roomAvatar"
|
||||
@change="handleRoomPickedAvatar($event)"
|
||||
accept="image/*"
|
||||
:accept="supportedAvatarImageTypes"
|
||||
class="d-none"
|
||||
/>
|
||||
</v-avatar>
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ export default {
|
|||
|
||||
const mime = blob.type;
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
if (util.isSupportedImageType(mime)) {
|
||||
var extension = ".png";
|
||||
switch (mime) {
|
||||
case "image/jpeg":
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@
|
|||
v-on:message-retention-update="onMessageRetention"
|
||||
/>
|
||||
|
||||
<ReportRoomDialog
|
||||
<ReportRoomOrEventDialog
|
||||
v-model="showReportDialog"
|
||||
:room="room"
|
||||
/>
|
||||
|
|
@ -364,7 +364,7 @@
|
|||
import LeaveRoomDialog from "../components/LeaveRoomDialog";
|
||||
import PurgeRoomDialog from "../components/PurgeRoomDialog";
|
||||
import MessageRetentionDialog from "../components/MessageRetentionDialog";
|
||||
import ReportRoomDialog from "../components/ReportRoomDialog";
|
||||
import ReportRoomOrEventDialog from "../components/ReportRoomOrEventDialog";
|
||||
import RoomExport from "../components/RoomExport";
|
||||
import RoomAvatarPicker from "../components/RoomAvatarPicker";
|
||||
import CopyLink from "../components/CopyLink.vue"
|
||||
|
|
@ -382,7 +382,7 @@ export default {
|
|||
LeaveRoomDialog,
|
||||
PurgeRoomDialog,
|
||||
MessageRetentionDialog,
|
||||
ReportRoomDialog,
|
||||
ReportRoomOrEventDialog,
|
||||
UserProfileDialog,
|
||||
RoomExport,
|
||||
RoomAvatarPicker,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
<template>
|
||||
<BottomSheet
|
||||
class="room-info-bottom-sheet"
|
||||
:halfY="0.12"
|
||||
ref="sheet"
|
||||
:showCloseButton="false"
|
||||
>
|
||||
<div class="room-info-sheet" ref="roomInfoSheetContent">
|
||||
<room-list v-on:close="close" v-on:newroom="createRoom" :showCreate="!$config.hide_add_room_on_home" />
|
||||
|
|
|
|||
|
|
@ -63,10 +63,6 @@ export default {
|
|||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.sheetContent {
|
||||
top: 40px !important;
|
||||
padding: 0 20px 20px 20px !important;
|
||||
}
|
||||
.sticker-picker {
|
||||
z-index: 10;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,52 @@
|
|||
<template>
|
||||
<div v-if="props.flags">
|
||||
<div class="cc-detail-info" v-if="infoText !== undefined" v-html="infoText" />
|
||||
<div class="attachment-info__detail-box" v-if="!showFlagsOnly && details.length > 0">
|
||||
<div class="detail-title">
|
||||
{{ t("cc.content_credentials") }}
|
||||
<v-icon>$vuetify.icons.ic_cr</v-icon>
|
||||
</div>
|
||||
<div class="detail-subtitle" v-if="hasC2PA">
|
||||
{{ t("cc.content_credentials_info") }}
|
||||
{{ t("cc.details") }}
|
||||
<v-icon v-if="hasC2PA">$vuetify.icons.ic_cr</v-icon>
|
||||
</div>
|
||||
|
||||
<div class="cc-detail-info" v-if="infoText !== undefined">
|
||||
{{ infoText }}
|
||||
<CCProperty v-for="d in details" :icon="d.icon" :title="d.title" :value="d.value">
|
||||
<template v-if="d.link" v-slot:default>
|
||||
<a :href="d.link">{{ d.value }}</a>
|
||||
</template>
|
||||
</CCProperty>
|
||||
</div>
|
||||
<div v-if="!showFlagsOnly">
|
||||
<div class="attachment-info__verify-info" v-if="hasC2PA">
|
||||
<i18n-t keypath="cc.cc_info" tag="span" style="position:relative">
|
||||
<template v-slot:link>
|
||||
<a :href="verifyLink" target="_blank">{{ $t("cc.cc_info_link") }}</a>
|
||||
<v-icon class="clickable" v-on:click="copyVerifyLink" color="white">$vuetify.icons.ic_copy</v-icon>
|
||||
<v-btn
|
||||
v-if="copiedVerifyLink"
|
||||
id="btn-copy-room-link"
|
||||
color="#333"
|
||||
variant="flat"
|
||||
style="position:absolute;right:0;bottom:0;color:white !important;"
|
||||
class="filled-button link-copied-in-place"
|
||||
>{{ $t("room_info.link_copied") }}</v-btn
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div class="attachment-info__verify-info" v-else-if="metadata">
|
||||
{{ t("cc.cc_no_info") }}
|
||||
</div>
|
||||
<div class="attachment-info__verify-info" v-else-if="props.metaStripped">
|
||||
{{ t("cc.metadata-stripped") }}
|
||||
</div>
|
||||
|
||||
<CCProperty
|
||||
v-if="props.flags?.device"
|
||||
icon="$vuetify.icons.ic_media_camera"
|
||||
:title="t('file_mode.cc_source')"
|
||||
:value="props.flags?.device"
|
||||
/>
|
||||
<CCProperty
|
||||
v-if="creationDate"
|
||||
icon="$vuetify.icons.ic_exif_time"
|
||||
:title="t('file_mode.cc_capture_timestamp')"
|
||||
:value="creationDate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Proof, ProofHintFlags } from "../../models/proof";
|
||||
import { Proof, MediaMetadata, MediaInterventionFlags, mediaMetadataToMediaInterventionFlags } from "../../models/proof";
|
||||
import { computed, ref, Ref, watch } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import CCProperty from "./CCProperty.vue";
|
||||
import CCProperty, { CCPropertyProps } from "./CCProperty.vue";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { interventionText } from "./intervention";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
|
|
@ -41,52 +54,77 @@ const { t } = useI18n();
|
|||
|
||||
const props = defineProps<{
|
||||
proof?: Proof;
|
||||
flags?: ProofHintFlags;
|
||||
metadata?: MediaMetadata;
|
||||
interventionFlags?: MediaInterventionFlags;
|
||||
metaStripped?: boolean;
|
||||
showFlagsOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const verifyLink = ref("https://contentcredentials.org/verify");
|
||||
const infoText: Ref<string | undefined> = ref(undefined);
|
||||
const creationDate: Ref<string | undefined> = ref(undefined);
|
||||
const details: Ref<(CCPropertyProps & { link?: string })[]> = ref([]);
|
||||
const copiedVerifyLink = ref(false);
|
||||
|
||||
const hasC2PA = computed(() => {
|
||||
return props.proof?.integrity?.c2pa !== undefined;
|
||||
});
|
||||
|
||||
const updateDetails = () => {
|
||||
let d: (CCPropertyProps & { link?: string })[] = [];
|
||||
|
||||
if (props.metadata?.device) {
|
||||
d.push({
|
||||
icon: "$vuetify.icons.ic_media_camera",
|
||||
title: t("file_mode.cc_source"),
|
||||
value: props.metadata?.device,
|
||||
});
|
||||
}
|
||||
|
||||
if (props.metadata?.creationDate) {
|
||||
d.push({
|
||||
icon: "$vuetify.icons.ic_exif_time",
|
||||
title: t("file_mode.cc_capture_timestamp"),
|
||||
value: dayjs(props.metadata.creationDate)?.format("lll"),
|
||||
});
|
||||
}
|
||||
|
||||
if (props.metadata?.location) {
|
||||
const lat = props.metadata.location.latitude;
|
||||
const lon = props.metadata.location.longitude;
|
||||
const location = lat + " " + lon;
|
||||
const link = "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent(lat) + "," + encodeURIComponent(lon);
|
||||
d.push({
|
||||
icon: "$vuetify.icons.ic_exif_location",
|
||||
title: t("file_mode.cc_location"),
|
||||
value: location,
|
||||
link: link,
|
||||
});
|
||||
}
|
||||
details.value = d;
|
||||
};
|
||||
|
||||
const copyVerifyLink = async () => {
|
||||
if (copiedVerifyLink.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(verifyLink.value);
|
||||
// Success!
|
||||
copiedVerifyLink.value = true;
|
||||
setInterval(() => {
|
||||
// Hide again
|
||||
copiedVerifyLink.value = false;
|
||||
}, 3000);
|
||||
} catch ($e) {
|
||||
console.error('Cannot copy');
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
props,
|
||||
() => {
|
||||
infoText.value = undefined;
|
||||
creationDate.value = undefined;
|
||||
|
||||
try {
|
||||
if (props.flags) {
|
||||
let date = props.flags.creationDate ? dayjs(props.flags.creationDate) : undefined;
|
||||
if (date) {
|
||||
creationDate.value = date.format("lll");
|
||||
}
|
||||
|
||||
let result = "";
|
||||
if (props.flags.generator === "camera") {
|
||||
result += t("file_mode.captured_with_camera");
|
||||
} else if (props.flags.generator === "screenshot") {
|
||||
if (date) {
|
||||
result += t("file_mode.captured_screenshot_ago", { ago: date.fromNow(true) });
|
||||
} else {
|
||||
result += t("file_mode.captured_screenshot");
|
||||
}
|
||||
} else if (props.flags.generator === "ai") {
|
||||
if (date) {
|
||||
result += t("file_mode.generated_with_ai_ago", { ago: date.fromNow(true) });
|
||||
} else {
|
||||
result += t("file_mode.generated_with_ai");
|
||||
}
|
||||
}
|
||||
if (date && dayjs().diff(date, "month") >= 3) {
|
||||
result += t("file_mode.old_photo");
|
||||
}
|
||||
|
||||
infoText.value = result === "" ? undefined : result;
|
||||
}
|
||||
} catch (error) {}
|
||||
infoText.value = props.metadata ?
|
||||
interventionText(t, mediaMetadataToMediaInterventionFlags(props.metadata)) :
|
||||
props.interventionFlags ? interventionText(t, props.interventionFlags) : undefined;
|
||||
updateDetails();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
export type CCPropertyProps = {
|
||||
icon: string;
|
||||
title: string;
|
||||
value?: string;
|
||||
}>();
|
||||
}
|
||||
const props = defineProps<CCPropertyProps>();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
<template>
|
||||
<div class="cc-summary" v-if="flags.length > 0 && infoText.length > 0">
|
||||
<v-icon class="intervention-icon">{{
|
||||
showCheck ? "$vuetify.icons.ic_intervention_check" : "$vuetify.icons.ic_intervention"
|
||||
}}</v-icon
|
||||
><span class="common-caption-small" v-html="infoText" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ProofHintFlags } from "../../models/proof";
|
||||
|
||||
const { multiple, flags } = defineProps<{
|
||||
multiple: boolean;
|
||||
flags: ProofHintFlags[];
|
||||
}>();
|
||||
|
||||
const showCheck = computed(() => {
|
||||
if (!multiple && flags.length == 1) {
|
||||
return flags[0].generatorSource === "c2pa";
|
||||
} else if (multiple) {
|
||||
return flags.some((f) => f.generatorSource === "c2pa")
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const infoText = computed(() => {
|
||||
if (!multiple && flags.length == 1) {
|
||||
if (flags[0].generator === "ai") {
|
||||
return "<b>This image is generated by AI.</b> Take a closer look at the file details.";
|
||||
} else if (flags[0].generator === "screenshot") {
|
||||
return "<b>This is a screenshot.</b> Take a closer look at the file details.";
|
||||
}
|
||||
} else if (flags.some((f) => f.generator === "ai")) {
|
||||
return "<b>Contains AI generated media.</b> Take a closer look at the file details for each.";
|
||||
} else if (flags.some((f) => f.generator === "screenshot")) {
|
||||
return "<b>Contains screenshots.</b> Take a closer look at the file details for each.";
|
||||
}
|
||||
return "TODO - Content Credentials Info";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
|
||||
.cc-summary {
|
||||
text-align: start;
|
||||
.intervention-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<template>
|
||||
<div v-if="exif">
|
||||
<div class="detail-title">
|
||||
{{ t("file_mode.exif_data") }}
|
||||
</div>
|
||||
<CCProperty v-if="makeAndModel" icon="$vuetify.icons.ic_exif_device_camera" :title="t('file_mode.cc_source')" :value="makeAndModel" />
|
||||
<CCProperty v-if="dateTime" icon="$vuetify.icons.ic_exif_time" :title="t('file_mode.cc_capture_timestamp')" :value="dateTime" />
|
||||
<CCProperty v-if="location" icon="$vuetify.icons.ic_exif_location" :title="t('file_mode.cc_location')">
|
||||
<template v-slot:default>
|
||||
<a :href="locationLink">{{ location }}</a>
|
||||
</template>
|
||||
</CCProperty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import dayjs from "dayjs";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import CCProperty from "./CCProperty.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
exif?: { [key: string]: string | Object };
|
||||
}>();
|
||||
const { exif } = props;
|
||||
|
||||
const getSimpleValue = (key: string): string | undefined => {
|
||||
return exif ? (exif[key] as string)?.replace(/^"(.+(?="$))"$/, "$1") : undefined;
|
||||
};
|
||||
|
||||
const toDegrees = (dms: string, direction: string) => {
|
||||
var parts = dms.split(/deg|min|sec/);
|
||||
var d = parts[0];
|
||||
var m = parts[1];
|
||||
var s = parts[2];
|
||||
var deg = (Number(d) + Number(m)/60 + Number(s)/3600).toFixed(6);
|
||||
if (direction == "S" || direction == "W") {
|
||||
deg = "-" + deg;
|
||||
}
|
||||
return deg;
|
||||
}
|
||||
|
||||
const getLocation = () => {
|
||||
try {
|
||||
if (exif) {
|
||||
const gpsLat = getSimpleValue("GPSLatitude");
|
||||
const gpsLon = getSimpleValue("GPSLongitude");
|
||||
if (gpsLat && gpsLon) {
|
||||
const lat = toDegrees(gpsLat, getSimpleValue("GPSLatitudeRef") ?? "");
|
||||
const lon = toDegrees(gpsLon, getSimpleValue("GPSLongitudeRef") ?? "");
|
||||
return {lat, lon};
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const location = computed(() => {
|
||||
const pos = getLocation();
|
||||
if (pos) {
|
||||
return pos.lat + " " + pos.lon;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const locationLink = computed(() => {
|
||||
const pos = getLocation();
|
||||
if (pos) {
|
||||
return "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent(pos.lat) + "," + encodeURIComponent(pos.lon);
|
||||
}
|
||||
return undefined;
|
||||
|
||||
|
||||
});
|
||||
|
||||
const dateTime = computed(() => {
|
||||
try {
|
||||
if (exif) {
|
||||
const date = getSimpleValue("DateTimeOriginal");
|
||||
const dateOffset = getSimpleValue("OffsetTimeOriginal");
|
||||
if (date) {
|
||||
return dayjs(Date.parse(date + (dateOffset ? dateOffset : ""))).format("lll");
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const makeAndModel = computed(() => {
|
||||
let result = "";
|
||||
if (exif) {
|
||||
const make = getSimpleValue("Make");
|
||||
const model = getSimpleValue("Model");
|
||||
if (make) {
|
||||
result += make;
|
||||
}
|
||||
if (model) {
|
||||
if (result.length > 0) {
|
||||
result += ", ";
|
||||
}
|
||||
result += model;
|
||||
}
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
</style>
|
||||
74
src/components/content-credentials/MediaIntervention.vue
Normal file
74
src/components/content-credentials/MediaIntervention.vue
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<div class="cc-summary bubble-inset" v-if="mediaInterventionFlags.length > 0 && infoText.length > 0">
|
||||
<v-icon class="intervention-icon">{{
|
||||
showCheck ? "$vuetify.icons.ic_intervention_check" : "$vuetify.icons.ic_intervention"
|
||||
}}</v-icon
|
||||
><span class="common-caption-small" v-html="infoText" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { MediaInterventionFlags } from "../../models/proof";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { interventionText } from "./intervention";
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { multiple, mediaInterventionFlags } = defineProps<{
|
||||
multiple: boolean;
|
||||
mediaInterventionFlags: MediaInterventionFlags[];
|
||||
}>();
|
||||
|
||||
const showCheck = computed(() => {
|
||||
if (!multiple && mediaInterventionFlags.length == 1) {
|
||||
return !!mediaInterventionFlags[0].containsC2PA;
|
||||
} else if (multiple) {
|
||||
return mediaInterventionFlags.every((f) => !!f.containsC2PA)
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const infoText = computed(() => {
|
||||
if (!multiple && mediaInterventionFlags.length == 1) {
|
||||
return interventionText(t, mediaInterventionFlags[0]) ?? "";
|
||||
} else if (multiple && mediaInterventionFlags.length >= 1) {
|
||||
const modifiedMedia = mediaInterventionFlags.some((f) => f.modified);
|
||||
const aiGeneratedMedia = mediaInterventionFlags.some((f) => f.generator === "ai");
|
||||
|
||||
if (aiGeneratedMedia && modifiedMedia) {
|
||||
return "<b>" + t("cc.contains_modified_and_ai_generated") + "</b> " + t("cc.take_a_closer_look");
|
||||
} else if (modifiedMedia) {
|
||||
return "<b>" + t("cc.contains_modified") + "</b>";
|
||||
} else if (aiGeneratedMedia) {
|
||||
return "<b>" + t("cc.contains_ai_generated") + "</b>";
|
||||
} else {
|
||||
return "<b>" + t("cc.information_available") + "</b>";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@/assets/css/chat.scss" as *;
|
||||
|
||||
.cc-summary {
|
||||
text-align: start;
|
||||
.intervention-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: inline-flex;
|
||||
color: #4642F1;
|
||||
}
|
||||
}
|
||||
|
||||
.messageIn.from-admin .cc-summary {
|
||||
.intervention-icon {
|
||||
color: #908EF7;
|
||||
}
|
||||
.common-caption-small {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
src/components/content-credentials/intervention.ts
Normal file
21
src/components/content-credentials/intervention.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { MediaInterventionFlags } from "../../models/proof";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export const interventionText = (t: any, f: MediaInterventionFlags): string | undefined => {
|
||||
let res = "";
|
||||
if (f.generator === "camera") {
|
||||
res += "<b>" + t("cc.captured_with_camera") + "</b> ";
|
||||
} else if (f.generator === "screenshot") {
|
||||
res += "<b>" + t("cc.screenshot_probably") + "</b> ";
|
||||
} else if (f.generator === "ai") {
|
||||
res += "<b>" + t("cc.generated_with_ai") + "</b> ";
|
||||
}
|
||||
if (f.modified) {
|
||||
res += "<b>" + t("cc.modified") + "</b> ";
|
||||
}
|
||||
if (f.creationDate && dayjs().diff(f.creationDate, "month") >= 3) {
|
||||
res += "<b>" + t("cc.older_than_n_months", { n: 3 }) + "</b> ";
|
||||
}
|
||||
if (res === "") return undefined;
|
||||
return res;
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<v-icon class="create-room-avatar__icon default" v-else>$vuetify.icons.room_avatar_placeholder</v-icon>
|
||||
<v-icon class="create-room-avatar__camera clickable" v-if="!modelValue || !modelValue.image" @click.stop="showRoomAvatarPicker">$vuetify.icons.ic_camera</v-icon>
|
||||
<input id="room-avatar-picker" ref="roomAvatar" type="file" name="roomAvatar"
|
||||
@change="handlePickedRoomAvatar($event)" accept="image/*" class="d-none" />
|
||||
@change="handlePickedRoomAvatar($event)" :accept="supportedAvatarImageTypes" class="d-none" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
<template>
|
||||
<div class="attachment-info">
|
||||
<div v-if="attachment.scaledFile" class="attachment-info__quality">
|
||||
<div v-if="attachment.compressedFile" class="attachment-info__quality">
|
||||
<div class="attachment-info__quality__title">
|
||||
{{ t("file_mode.quality") }}
|
||||
</div>
|
||||
<div class="d-flex justify-center">
|
||||
<div @click="attachment.useScaled = true" :class="{ 'attachment-info__quality__class': true, selected: attachment.useScaled, clickable: true }">
|
||||
<v-icon>$vuetify.icons.ic_scaled</v-icon>
|
||||
<div @click="attachment.useCompressed = true" :class="{ 'attachment-info__quality__class': true, selected: attachment.useCompressed, clickable: true }">
|
||||
<v-icon>$vuetify.icons.ic_compressed</v-icon>
|
||||
<div class="attachment-info__quality__class-name">{{ t("file_mode.compressed") }}</div>
|
||||
<div class="attachment-info__quality__class-size">
|
||||
<span>{{ attachment.scaledDimensions?.width }} x {{ attachment.scaledDimensions?.height }}</span>
|
||||
<span> ({{ formatBytes(attachment.scaledFile.size) }})</span>
|
||||
<span>{{ attachment.compressedDimensions?.width }} x {{ attachment.compressedDimensions?.height }}</span>
|
||||
<span> ({{ formatBytes(attachment.compressedFile.size) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div @click="attachment.useScaled = false" :class="{ 'attachment-info__quality__class': true, selected: !attachment.useScaled, clickable: true }">
|
||||
<div @click="attachment.useCompressed = false" :class="{ 'attachment-info__quality__class': true, selected: !attachment.useCompressed, clickable: true }">
|
||||
<v-icon>$vuetify.icons.ic_original</v-icon>
|
||||
<div class="attachment-info__quality__class-name">{{ t("file_mode.original") }}</div>
|
||||
<div class="attachment-info__quality__class-size">
|
||||
|
|
@ -24,12 +24,11 @@
|
|||
</div>
|
||||
|
||||
<div class="attachment-info__quality__info">
|
||||
{{ t(attachment.useScaled ? "file_mode.metadata_info_compressed" : "file_mode.metadata_info_original") }}
|
||||
{{ t(attachment.useCompressed ? "file_mode.metadata_info_compressed" : "file_mode.metadata_info_original") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<C2PAInfo class="attachment-info__detail-box" v-if="showC2PAInfo" :proof="attachment.proof" :flags="attachment.proofHintFlags" />
|
||||
<EXIFInfo class="attachment-info__detail-box" v-if="hasExif" :exif="attachment.proof?.integrity?.exif" />
|
||||
<C2PAInfo class="attachment-info__detail-box" v-if="showC2PAInfo || hasExif" :proof="attachment.proof" :metadata="attachment.mediaMetadata" :showFlagsOnly="attachment.useCompressed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -37,7 +36,6 @@
|
|||
import { Attachment } from "../../models/attachment";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import C2PAInfo from "../content-credentials/C2PAInfo.vue";
|
||||
import EXIFInfo from "../content-credentials/EXIFInfo.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { computed } from "vue";
|
||||
|
||||
|
|
@ -50,7 +48,7 @@ const { attachment } = defineProps<{
|
|||
//console.error("ATTACHMENT", attachment.proof);
|
||||
|
||||
const showC2PAInfo = computed(() => {
|
||||
return attachment.proof?.integrity?.c2pa !== undefined || attachment.proofHintFlags !== undefined;
|
||||
return attachment.proof?.integrity?.c2pa !== undefined || attachment.mediaMetadata !== undefined;
|
||||
});
|
||||
|
||||
const hasExif = computed(() => {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
<template>
|
||||
<div class="attachment-info">
|
||||
<div class="attachment-info__content">
|
||||
<div v-if="loadingProof">
|
||||
<div style="font-size: 0.7em; opacity: 0.7">
|
||||
<v-progress-circular indeterminate class="mb-0"></v-progress-circular>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="metaStripped" class="cc-detail-info white-space-pre">{{ t("cc.metadata-stripped") }}</div>
|
||||
<C2PAInfo class="attachment-info__detail-box" :proof="attachment?.proof" :flags="attachment?.proofHintFlags" />
|
||||
<EXIFInfo class="attachment-info__detail-box" v-if="hasExif" :exif="attachment?.proof?.integrity?.exif" />
|
||||
<C2PAInfo :proof="attachment?.proof" :metadata="attachment?.mediaMetadata" :interventionFlags="attachment?.mediaInterventionFlags" :metaStripped="metaStripped" />
|
||||
</div>
|
||||
</div>
|
||||
<v-btn variant="flat" block class="attachment-info__download-button" @click.stop="() => {emits('download');}">
|
||||
<v-icon color="white" class="mr-2">$vuetify.icons.ic_download</v-icon>{{ $t("cc.download_image") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import C2PAInfo from "../content-credentials/C2PAInfo.vue";
|
||||
import EXIFInfo from "../content-credentials/EXIFInfo.vue";
|
||||
import { computed, onMounted, Ref, ref } from "vue";
|
||||
import { onMounted, Ref, ref } from "vue";
|
||||
import { EventAttachment } from "../../models/eventAttachment";
|
||||
import proofmode from "../../plugins/proofmode";
|
||||
import { extractProofHintFlags } from "../../models/proof";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
import { extractMediaMetadata } from "../../models/proof";
|
||||
|
||||
const { attachment } = defineProps<{
|
||||
attachment?: EventAttachment;
|
||||
|
|
@ -31,8 +30,18 @@ const { attachment } = defineProps<{
|
|||
const loadingProof: Ref<boolean> = ref(false);
|
||||
const metaStripped: Ref<boolean> = ref(false);
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: "download"): void;
|
||||
}>();
|
||||
|
||||
const updateMetaStripped = (a: EventAttachment | undefined) => {
|
||||
const hadC2PA = a?.mediaInterventionFlags ? a.mediaInterventionFlags.containsC2PA : false;
|
||||
const hadExif = a?.mediaInterventionFlags ? a.mediaInterventionFlags.containsEXIF : false;
|
||||
metaStripped.value = (!!hadC2PA && a?.proof?.integrity?.c2pa === undefined) || (!!hadExif && a?.proof?.integrity?.exif === undefined);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (attachment?.proofHintFlags && attachment.proof === undefined) {
|
||||
if ((attachment?.mediaMetadata || attachment?.mediaInterventionFlags) && attachment.proof === undefined) {
|
||||
const a = attachment;
|
||||
loadingProof.value = true;
|
||||
metaStripped.value = true;
|
||||
|
|
@ -41,8 +50,11 @@ onMounted(() => {
|
|||
if (data && data.data) {
|
||||
return proofmode.proofCheckSource(data.data).then((res) => {
|
||||
a.proof = res;
|
||||
a.proofHintFlags = extractProofHintFlags(a.proof);
|
||||
metaStripped.value = a?.proof?.integrity?.c2pa === undefined && a?.proof?.integrity?.exif === undefined;
|
||||
if (res?.integrity?.c2pa) {
|
||||
// If we have proof, overwrite the flags
|
||||
a.mediaMetadata = extractMediaMetadata(a.proof);
|
||||
}
|
||||
updateMetaStripped(a);
|
||||
});
|
||||
}
|
||||
})
|
||||
|
|
@ -51,17 +63,9 @@ onMounted(() => {
|
|||
loadingProof.value = false;
|
||||
});
|
||||
} else {
|
||||
metaStripped.value = attachment?.proof?.integrity?.c2pa === undefined && attachment?.proof?.integrity?.exif === undefined;
|
||||
updateMetaStripped(attachment);
|
||||
}
|
||||
});
|
||||
|
||||
const hasC2PA = computed(() => {
|
||||
return attachment?.proof?.integrity?.c2pa !== undefined;
|
||||
});
|
||||
|
||||
const hasExif = computed(() => {
|
||||
return attachment?.proof?.integrity?.exif !== undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
|||
|
|
@ -5,19 +5,17 @@
|
|||
<v-icon @click.stop="$emit('close')" color="white" class="clickable">arrow_back</v-icon>
|
||||
<div class="room-name no-upper">{{ displayDate }}</div>
|
||||
<div>
|
||||
<v-icon @click.stop="showInfo = true" v-if="showInfoButton" color="white" class="clickable"
|
||||
<v-icon @click.stop="showInfo = true" v-if="showInfoButton" color="white" class="header-button clickable"
|
||||
>info_outline</v-icon
|
||||
>
|
||||
<v-icon @click.stop="showMoreMenu = true" color="white" class="clickable">more_vert</v-icon>
|
||||
<v-icon v-if="items.length > 1" @click.stop="showMoreMenu = true" color="white" class="header-button clickable">more_vert</v-icon>
|
||||
<v-icon v-else @click.stop="() => downloadOne()" color="white" class="header-button clickable">$vuetify.icons.ic_download</v-icon>
|
||||
</div>
|
||||
</v-container>
|
||||
</div>
|
||||
|
||||
<div class="gallery-current-item">
|
||||
<ThumbnailView :item="currentAttachment" />
|
||||
<div class="download-button clickable" @click.stop="downloadOne">
|
||||
<v-icon color="black">arrow_downward</v-icon>
|
||||
</div>
|
||||
<ThumbnailView :item="currentAttachment" showInlinePDF />
|
||||
</div>
|
||||
<div class="gallery-thumbnail-container">
|
||||
<div
|
||||
|
|
@ -43,7 +41,7 @@
|
|||
</v-card-title>
|
||||
<v-card-title class="d-flex"> </v-card-title>
|
||||
<v-card-text>
|
||||
<EventAttachmentInfo :attachment="currentAttachment" />
|
||||
<EventAttachmentInfo :attachment="currentAttachment" v-on:download="downloadOne" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-bottom-sheet>
|
||||
|
|
@ -86,13 +84,18 @@ const displayDate = computed(() => {
|
|||
});
|
||||
|
||||
const showInfoButton = computed(() => {
|
||||
return currentAttachment.value?.proofHintFlags !== undefined;
|
||||
return currentAttachment.value?.mediaMetadata !== undefined || currentAttachment.value?.mediaInterventionFlags !== undefined;
|
||||
});
|
||||
|
||||
const moreMenuItems = computed(() => {
|
||||
let items = [];
|
||||
items.push({
|
||||
icon: "$vuetify.icons.ic_download",
|
||||
text: t("message.download_media"),
|
||||
handler: () => {
|
||||
downloadOne();
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
text: t("message.download_all"),
|
||||
handler: () => {
|
||||
downloadAll();
|
||||
|
|
@ -132,21 +135,11 @@ const downloadAll = () => {
|
|||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
// .file-drop-current-item {
|
||||
// position: relative;
|
||||
// }
|
||||
|
||||
.download-button {
|
||||
position: absolute;
|
||||
right: 21px;
|
||||
bottom: 21px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 17px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.header-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.fill-screen {
|
||||
|
|
|
|||
|
|
@ -1,36 +1,39 @@
|
|||
<template>
|
||||
<div v-bind="{ ...$attrs }" class="send-attachments">
|
||||
<v-btn
|
||||
v-if="!fileModeMode"
|
||||
class="back-button clickable"
|
||||
icon="arrow_back"
|
||||
size="default"
|
||||
elevation="0"
|
||||
@click.stop="close"
|
||||
:disabled="backButtonDisabled"
|
||||
variant="flat"
|
||||
></v-btn>
|
||||
<v-btn v-if="!fileModeMode" class="back-button clickable" icon="arrow_back" size="default" elevation="0"
|
||||
@click.stop="close" :disabled="backButtonDisabled" variant="flat"></v-btn>
|
||||
|
||||
<div class="title">{{ title }}</div>
|
||||
<div v-if="fileModeMode" class="title file-drop">
|
||||
<v-icon>$vuetify.icons.ic_lock</v-icon>
|
||||
<div class="file-drop-title">{{ $t("file_mode.secure_file_send") }}</div>
|
||||
</div>
|
||||
<div v-else class="title">{{ title }}</div>
|
||||
|
||||
<!-- No attachments view -->
|
||||
<template v-if="fileModeMode && status == mainStatuses.SELECTING && batch.attachments.length == 0">
|
||||
<div :class="{ 'file-drop-choose-files': true, 'drop-target': dropTarget }" @drop.prevent="filesDropped"
|
||||
@dragover.prevent="dropTarget = true" @dragleave.prevent="dropTarget = false"
|
||||
@dragenter.prevent="dropTarget = true">
|
||||
<v-btn @click="$emit('pick-file')" size="large">{{ $t("file_mode.choose_files") }}</v-btn>
|
||||
<div class="file-format-info">{{ $t("file_mode.any_file_format_accepted") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ATTACHMENT SELECTION MODE -->
|
||||
<template v-if="status == mainStatuses.SELECTING">
|
||||
<div
|
||||
:class="{ 'send-attachments__selecting__current-item': true, 'drop-target': dropTarget }"
|
||||
@drop.prevent="filesDropped"
|
||||
@dragover.prevent="dropTarget = true"
|
||||
@dragleave.prevent="dropTarget = false"
|
||||
@dragenter.prevent="dropTarget = true"
|
||||
>
|
||||
<ThumbnailView :item="currentAttachment" />
|
||||
<div v-if="currentAttachment && currentAttachment.status === 'loading'" class="send-attachments__selecting__current-item__preparing">
|
||||
<template v-else-if="status == mainStatuses.SELECTING">
|
||||
<div :class="{ 'send-attachments__selecting__current-item': true, 'drop-target': dropTarget }"
|
||||
@drop.prevent="filesDropped" @dragover.prevent="dropTarget = true" @dragleave.prevent="dropTarget = false"
|
||||
@dragenter.prevent="dropTarget = true">
|
||||
<ThumbnailView :item="currentAttachment">
|
||||
<template v-slot:decorator v-if="currentAttachment && currentAttachment.status === 'loading'">
|
||||
<div style="font-size: 0.7em; opacity: 0.7">
|
||||
<v-progress-circular indeterminate class="mb-0"></v-progress-circular>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="showCCIcon" class="send-attachments__selecting__current-item__cc">
|
||||
</template>
|
||||
<template v-slot:decorator v-else-if="showCCIcon">
|
||||
<v-icon style="width:24px;height:24px">$vuetify.icons.ic_cr</v-icon>
|
||||
</div>
|
||||
</template>
|
||||
</ThumbnailView>
|
||||
|
||||
</div>
|
||||
<div class="file-drop-thumbnail-container">
|
||||
|
|
@ -38,20 +41,14 @@
|
|||
<template v-slot:activator="{ props }">
|
||||
<v-badge :model-value="batch.isTooLarge(attachment)" color="error">
|
||||
<template v-slot:badge><span v-bind="props"> </span></template>
|
||||
<div
|
||||
:class="{ 'file-drop-thumbnail': true, clickable: true, current: index == currentItemIndex }"
|
||||
@click="
|
||||
<div :class="{ 'file-drop-thumbnail': true, clickable: true, current: index == currentItemIndex }" @click="
|
||||
() => {
|
||||
currentItemIndex = index;
|
||||
}
|
||||
"
|
||||
>
|
||||
">
|
||||
<v-img v-if="attachment && attachment.src" :src="attachment.src" />
|
||||
<div
|
||||
v-if="currentItemIndex == index"
|
||||
class="remove clickable"
|
||||
@click.stop="batch.removeAttachment(attachment)"
|
||||
>
|
||||
<div v-if="currentItemIndex == index" class="remove clickable"
|
||||
@click.stop="batch.removeAttachment(attachment)">
|
||||
<v-icon>$vuetify.icons.ic_trash</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -65,40 +62,15 @@
|
|||
</div>
|
||||
<div class="file-drop-input-container">
|
||||
<div class="file-drop-input-container__input">
|
||||
<v-text-field
|
||||
ref="input"
|
||||
full-width
|
||||
variant="solo"
|
||||
flat
|
||||
v-model="messageInput"
|
||||
no-resize
|
||||
class="input-area-text"
|
||||
rows="1"
|
||||
:placeholder="$t('file_mode.add_a_message')"
|
||||
hide-details
|
||||
color="white"
|
||||
background-color="transparent"
|
||||
/>
|
||||
<v-btn
|
||||
class="send-button clickable"
|
||||
icon="arrow_upward"
|
||||
size="default"
|
||||
elevation="0"
|
||||
color="black"
|
||||
@click.stop="sendAll"
|
||||
:disabled="sendButtonDisabled"
|
||||
></v-btn>
|
||||
<v-text-field ref="input" full-width variant="solo" flat v-model="messageInput" no-resize
|
||||
class="input-area-text" rows="1" :placeholder="$t('file_mode.add_a_message')" hide-details color="white"
|
||||
background-color="transparent" />
|
||||
<v-btn class="send-button clickable" icon="send" size="default" elevation="0" color="black"
|
||||
@click.stop="sendAll" :disabled="sendButtonDisabled"></v-btn>
|
||||
</div>
|
||||
<v-badge location="top right" color="#ff3300" dot class="cc-badge" :model-value="anyContainsCC">
|
||||
<v-btn
|
||||
class="info-button clickable"
|
||||
icon="$vuetify.icons.ic_share_settings"
|
||||
size="44"
|
||||
elevation="0"
|
||||
color="black"
|
||||
@click.stop="showInformation"
|
||||
:disabled="currentAttachment?.status !== 'loaded'"
|
||||
></v-btn>
|
||||
<v-badge location="top right" color="#ff3300" dot class="cc-badge" :model-value="showRedDotBadge">
|
||||
<v-btn class="info-button clickable" icon="$vuetify.icons.ic_share_settings" size="44" elevation="0"
|
||||
color="black" @click.stop="showInformation" :disabled="currentAttachment?.status !== 'loaded'"></v-btn>
|
||||
</v-badge>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -110,13 +82,8 @@
|
|||
<div class="file-drop-stack-item direct" :style="stackItemTransform(null, -1)"></div>
|
||||
<div>{{ $t("file_mode.sending_progress") }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="(info, index) in batch.attachmentsSent"
|
||||
:key="info.file.name"
|
||||
class="file-drop-stack-item animated"
|
||||
:style="stackItemTransform(info, index)"
|
||||
>
|
||||
<div v-else v-for="(info, index) in batch.attachmentsSent" :key="info.file.name"
|
||||
class="file-drop-stack-item animated" :style="stackItemTransform(info, index)">
|
||||
<v-img v-if="info.src" :src="info.src" />
|
||||
</div>
|
||||
<div v-if="status == mainStatuses.SENT" class="items-sent" :style="stackItemTransform(null, -1)">
|
||||
|
|
@ -145,43 +112,21 @@
|
|||
}}
|
||||
</div>
|
||||
<div class="file-drop-section">
|
||||
<v-textarea
|
||||
disabled
|
||||
full-width
|
||||
variant="solo"
|
||||
flat
|
||||
v-model="messageInput"
|
||||
no-resize
|
||||
class="input-area-text"
|
||||
rows="1"
|
||||
hide-details
|
||||
background-color="transparent"
|
||||
/>
|
||||
<v-textarea disabled full-width variant="solo" flat v-model="messageInput" no-resize class="input-area-text"
|
||||
rows="1" hide-details background-color="transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom section -->
|
||||
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-input-container">
|
||||
<v-textarea
|
||||
disabled
|
||||
full-width
|
||||
variant="solo"
|
||||
flat
|
||||
auto-grow
|
||||
v-model="messageInput"
|
||||
no-resize
|
||||
class="input-area-text"
|
||||
rows="1"
|
||||
:placeholder="$t('file_mode.add_a_message')"
|
||||
hide-details
|
||||
background-color="transparent"
|
||||
/>
|
||||
<v-btn
|
||||
>{{ $t("file_mode.sending")
|
||||
}}<v-progress-circular indeterminate size="18" width="2" color="#4642F1"></v-progress-circular
|
||||
></v-btn>
|
||||
<v-textarea disabled full-width variant="solo" flat auto-grow v-model="messageInput" no-resize
|
||||
class="input-area-text" rows="1" :placeholder="$t('file_mode.add_a_message')" hide-details
|
||||
background-color="transparent" />
|
||||
<v-btn>{{ $t("file_mode.sending")
|
||||
}}<v-progress-circular indeterminate size="18" width="2" color="#4642F1"></v-progress-circular></v-btn>
|
||||
</div>
|
||||
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sent-input-container">
|
||||
<v-btn class="text" @click.stop="reset">{{ $t("file_mode.send_more_files") }}</v-btn>
|
||||
<v-btn class="close" @click.stop="close">{{ $t("file_mode.close") }}</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -189,23 +134,17 @@
|
|||
<v-bottom-sheet v-model="showAttachmentInformation" theme="dark" height="80%">
|
||||
<v-card class="text-center send-attachments-info-popup">
|
||||
<v-card-title class="d-flex flex-column pa-0">
|
||||
<div class="align-self-end done-button clickable" @click="showAttachmentInformation = false">{{ $t("menu.done") }}</div>
|
||||
<div class="align-self-end done-button clickable" @click="showAttachmentInformation = false">{{
|
||||
$t("menu.done") }}
|
||||
</div>
|
||||
<v-divider />
|
||||
</v-card-title>
|
||||
<v-card-title class="d-flex">
|
||||
<v-btn
|
||||
class="left-right-arrow flex-0-0"
|
||||
icon="chevron_left"
|
||||
@click.stop="currentItemIndex -= 1"
|
||||
:disabled="currentItemIndex == 0"
|
||||
></v-btn>
|
||||
<v-btn class="left-right-arrow flex-0-0" icon="chevron_left" @click.stop="currentItemIndex -= 1"
|
||||
:disabled="currentItemIndex == 0"></v-btn>
|
||||
<div class="title flex-fill">{{ currentAttachment.file.name }}</div>
|
||||
<v-btn
|
||||
class="left-right-arrow flex-0-0"
|
||||
icon="chevron_right"
|
||||
@click.stop="currentItemIndex += 1"
|
||||
:disabled="currentItemIndex >= batch.attachments.length - 1"
|
||||
></v-btn>
|
||||
<v-btn class="left-right-arrow flex-0-0" icon="chevron_right" @click.stop="currentItemIndex += 1"
|
||||
:disabled="currentItemIndex >= batch.attachments.length - 1"></v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<AttachmentInfo :attachment="currentAttachment" />
|
||||
|
|
@ -289,11 +228,17 @@ export default defineComponent({
|
|||
}
|
||||
return undefined;
|
||||
},
|
||||
anyContainsCC(): boolean {
|
||||
return this.batch.attachments.some((a: Attachment) => a.proof?.integrity?.c2pa !== undefined);
|
||||
showRedDotBadge(): boolean {
|
||||
return this.currentAttachment && this.currentAttachment.proof?.integrity?.c2pa !== undefined && !this.currentAttachment.detailsViewed;
|
||||
},
|
||||
showCCIcon(): boolean {
|
||||
return this.currentAttachment && this.currentAttachment.proof?.integrity?.c2pa !== undefined && !this.currentAttachment.useScaled;
|
||||
},
|
||||
currentlyViewedItem(): Attachment | undefined {
|
||||
if (this.showAttachmentInformation) {
|
||||
return this.currentAttachment;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
@ -308,6 +253,11 @@ export default defineComponent({
|
|||
},
|
||||
deep: 1,
|
||||
},
|
||||
currentlyViewedItem(newVal) {
|
||||
if (newVal) {
|
||||
newVal.detailsViewed = true; // We have seen this now
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showInformation() {
|
||||
|
|
@ -326,6 +276,13 @@ export default defineComponent({
|
|||
}
|
||||
this.batch.addFiles(files);
|
||||
},
|
||||
reset() {
|
||||
this.sendingAttachments = [];
|
||||
this.status = this.mainStatuses.SELECTING;
|
||||
this.messageInput = "";
|
||||
this.currentItemIndex = 0;
|
||||
this.$emit('reset');
|
||||
},
|
||||
close() {
|
||||
this.batch.cancel();
|
||||
this.status = this.mainStatuses.SELECTING;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
<video :src="source" :poster="poster" :controls="!previewOnly" class="w-100 h-100">
|
||||
{{ $t("fallbacks.video_file") }}
|
||||
</video>
|
||||
<div class="video-icon" v-if="previewOnly">
|
||||
<v-icon class="icon" size="34">$vuetify.icons.ic_video</v-icon>
|
||||
</div>
|
||||
</v-responsive>
|
||||
<ImageWithProgress
|
||||
v-else-if="isImage"
|
||||
|
|
@ -13,22 +16,47 @@
|
|||
:contain="!previewOnly"
|
||||
:cover="previewOnly"
|
||||
:loadingProgress="loadingProgress"
|
||||
ref="imageRef"
|
||||
v-on:loaded="imageLoaded"
|
||||
/>
|
||||
<div v-else :class="{ 'thumbnail-item': true, preview: previewOnly, 'file-item': true }">
|
||||
<div v-else :class="{ 'thumbnail-item': true, preview: previewOnly, 'file-item': true, 'pdf-file': isPDF }">
|
||||
<div class="pdf-container" v-if="showInlinePDF && isPDF">
|
||||
<div
|
||||
v-for="(pageNum, index) in pageNums"
|
||||
:key="pageNum"
|
||||
:ref="(el) => { if ((el != null && el as Element)) pageRefs[index] = el as Element }"
|
||||
>
|
||||
<vue-pdf-embed
|
||||
v-if="pageVisibility[pageNum]"
|
||||
:source="doc"
|
||||
:page="pageNum"
|
||||
:width="!util.isMobileOrTabletBrowser() ? 800 : undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<v-icon :class="fileTypeIconClass">{{ fileTypeIcon }}</v-icon>
|
||||
<div class="file-name">{{ $$sanitize(fileName) }}</div>
|
||||
<div class="file-size">{{ fileSize }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div :style="decoratorStyle" id="asdi">
|
||||
<slot name="decorator"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { singleOrDoubleTapRecognizer } from "../../plugins/touch";
|
||||
import { computed, inject, onBeforeUnmount, onMounted, Ref, ref, useTemplateRef, watch } from "vue";
|
||||
import { computed, inject, onBeforeUnmount, onMounted, Ref, ref, useTemplateRef, watch, nextTick } from "vue";
|
||||
import { EventAttachment } from "../../models/eventAttachment";
|
||||
import { useThumbnail } from "../messages/composition/useThumbnail";
|
||||
import { Attachment } from "../../models/attachment";
|
||||
import ImageWithProgress from "../ImageWithProgress.vue";
|
||||
import VuePdfEmbed, { useVuePdfEmbed } from 'vue-pdf-embed';
|
||||
import { Source } from 'vue-pdf-embed/types';
|
||||
import util from "@/plugins/utils";
|
||||
|
||||
function isEventAttachment(source: EventAttachment | Attachment | undefined): source is EventAttachment {
|
||||
return (source as EventAttachment)?.event !== undefined;
|
||||
|
|
@ -41,10 +69,12 @@ function isAttachment(source: EventAttachment | Attachment | undefined): source
|
|||
const $$sanitize: any = inject("globalSanitize");
|
||||
|
||||
const thumbnailRef = useTemplateRef("thumbnailRef");
|
||||
const imageRef = useTemplateRef("imageRef");
|
||||
|
||||
interface ThumbnailProps {
|
||||
item?: EventAttachment | Attachment;
|
||||
previewOnly?: boolean;
|
||||
showInlinePDF?: boolean;
|
||||
}
|
||||
|
||||
type ThumbnailEmits = {
|
||||
|
|
@ -52,14 +82,15 @@ type ThumbnailEmits = {
|
|||
};
|
||||
|
||||
const props = defineProps<ThumbnailProps>();
|
||||
const { item, previewOnly = false } = props;
|
||||
const { item, previewOnly = false, showInlinePDF = false } = props;
|
||||
const emits = defineEmits<ThumbnailEmits>();
|
||||
|
||||
let { isVideo, isImage, fileTypeIcon, fileTypeIconClass, fileName, fileSize } = useThumbnail(isEventAttachment(props.item) ? props.item.event : isAttachment(props.item) ? props.item.file : undefined);
|
||||
let { isVideo, isImage, fileTypeIcon, fileTypeIconClass, fileName, fileSize, isPDF } = useThumbnail(isEventAttachment(props.item) ? props.item.event : isAttachment(props.item) ? props.item.file : undefined);
|
||||
|
||||
const fileURL: Ref<string | undefined> = ref(undefined);
|
||||
const source: Ref<string | undefined> = ref(undefined);
|
||||
const poster: Ref<string | undefined> = ref(undefined);
|
||||
const decoratorStyle: Ref<string | undefined> = ref(undefined);
|
||||
|
||||
const updateSource = () => {
|
||||
if (isEventAttachment(props.item)) {
|
||||
|
|
@ -112,11 +143,39 @@ const updatePoster = () => {
|
|||
updateSource();
|
||||
updatePoster();
|
||||
|
||||
const imageLoaded = (image: any) => {
|
||||
if (imageRef.value) {
|
||||
const rect = image.$el.getBoundingClientRect();
|
||||
const nw = image.naturalWidth;
|
||||
const nh = image.naturalHeight;
|
||||
|
||||
let t = 0;
|
||||
let l = 0;
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
const wRatio = rect.width / rect.height;
|
||||
const iRatio = nw / nh;
|
||||
if (iRatio > wRatio) {
|
||||
w = rect.width;
|
||||
h = rect.width / iRatio;
|
||||
} else {
|
||||
w = rect.height * iRatio;
|
||||
h = rect.height;
|
||||
}
|
||||
|
||||
l = (rect.width - w) / 2;
|
||||
t = (rect.height -h) / 2;
|
||||
|
||||
decoratorStyle.value = `position:absolute;top:${t}px;left:${l}px;width:${w}px;height:${h}px;display:flex;align-items:end;justify-content:end;padding:10px`;
|
||||
}
|
||||
}
|
||||
|
||||
watch(props, (props: ThumbnailProps) => {
|
||||
const updates = useThumbnail(isEventAttachment(props.item) ? props.item.event : isAttachment(props.item) ? props.item.file : undefined);
|
||||
isVideo.value = updates.isVideo.value;
|
||||
isImage.value = updates.isImage.value;
|
||||
isPDF.value = updates.isPDF.value;
|
||||
fileTypeIcon = updates.fileTypeIcon;
|
||||
fileTypeIconClass = updates.fileTypeIconClass;
|
||||
fileName = updates.fileName;
|
||||
|
|
@ -125,6 +184,53 @@ watch(props, (props: ThumbnailProps) => {
|
|||
updatePoster();
|
||||
});
|
||||
|
||||
const pageRefs: Ref<(Element)[]> = ref([]);
|
||||
const pageVisibility: Ref<{ [n: number]: boolean }> = ref({});
|
||||
let pageIntersectionObserver: IntersectionObserver | null = null;
|
||||
const pdfSource: Ref<Source | null> = ref(null);
|
||||
|
||||
const { doc } = useVuePdfEmbed({
|
||||
source: pdfSource,
|
||||
});
|
||||
|
||||
const initVuePdfEmbed = computed(()=> props.showInlinePDF && isPDF.value)
|
||||
|
||||
const pageNums = computed(() =>
|
||||
doc.value ? [...Array(doc.value.numPages + 1).keys()].slice(1) : []
|
||||
);
|
||||
|
||||
const resetPageIntersectionObserver = () => {
|
||||
if(!initVuePdfEmbed.value) return
|
||||
pageIntersectionObserver?.disconnect()
|
||||
pageIntersectionObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const index = pageRefs.value.findIndex((el) => el === entry.target)
|
||||
if (index !== -1) {
|
||||
const pageNum = pageNums.value[index]
|
||||
pageVisibility.value[pageNum] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
pageRefs.value.forEach((element) => {
|
||||
if (element && pageIntersectionObserver) pageIntersectionObserver.observe(element)
|
||||
})
|
||||
};
|
||||
|
||||
watch(pageNums, (newPageNums) => {
|
||||
if(!initVuePdfEmbed.value) return
|
||||
pageVisibility.value = { [newPageNums[0]]: true }
|
||||
pageRefs.value = []
|
||||
nextTick(resetPageIntersectionObserver)
|
||||
});
|
||||
|
||||
watch(source, (newSource) => {
|
||||
if(!initVuePdfEmbed.value) return
|
||||
pdfSource.value = isPDF.value ? newSource : null;
|
||||
});
|
||||
|
||||
const loadingProgress = computed(() => {
|
||||
if (isEventAttachment(item)) {
|
||||
const eventAttachment = item;
|
||||
|
|
@ -152,6 +258,7 @@ onBeforeUnmount(() => {
|
|||
URL.revokeObjectURL(fileURL.value);
|
||||
fileURL.value = undefined;
|
||||
}
|
||||
pageIntersectionObserver?.disconnect()
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -163,6 +270,17 @@ onBeforeUnmount(() => {
|
|||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&.pdf-file {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.video-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.file-item {
|
||||
|
|
@ -177,4 +295,19 @@ onBeforeUnmount(() => {
|
|||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.pdf-container {
|
||||
margin-top: 100px;
|
||||
height: 100vh;
|
||||
|
||||
& > * {
|
||||
margin: 30px auto 0 auto;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.vue-pdf-embed {
|
||||
border: 1px solid #ccc;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,38 @@ export default {
|
|||
return {
|
||||
languages: [],
|
||||
activeLang:null,
|
||||
displayLanguage: ['en','bo','zh_Hans','ug']
|
||||
displayLanguage: ['en','bo','zh_Hans','ug'],
|
||||
languageDisplayName: {
|
||||
"ar": "العربية",
|
||||
"bn": "বাংলা",
|
||||
"bo": "བོད་ཡིག",
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"es_419": "Español (Latinoamérica)",
|
||||
"es_CU": "Español (Cuba)",
|
||||
"es": "Español",
|
||||
"fa_AF": "دری",
|
||||
"fa": "فارسی",
|
||||
"fi": "suomi",
|
||||
"fr": "français",
|
||||
"ga": "Gaeilge",
|
||||
"it": "italiano",
|
||||
"km": "ខ្មែរ",
|
||||
"ku": "Kurdî",
|
||||
"lo": "ລາວ",
|
||||
"my": "မြန်မာ",
|
||||
"nb_NO": "Norsk",
|
||||
"ps": "پښتو",
|
||||
"pt_BR": "Português (Brasil)",
|
||||
"pt_PT": "Português (Portugal)",
|
||||
"ro": "Română",
|
||||
"ru": "Русский",
|
||||
"si": "සිංහල",
|
||||
"tr": "Türkçe",
|
||||
"ug": "ئۇيغۇرچە",
|
||||
"uk": "Українська",
|
||||
"zh_Hans": "简体中文"
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -19,8 +50,8 @@ export default {
|
|||
const context = this
|
||||
for (const locale of Object.keys(this.$i18n.messages)) {
|
||||
this.languages.push({
|
||||
title: this.$i18n.messages[locale].language_display_name || locale,
|
||||
text: this.$i18n.messages[locale].language_display_name || locale,
|
||||
title: this.languageDisplayName[locale],
|
||||
text: this.languageDisplayName[locale],
|
||||
value: locale,
|
||||
display: context.displayLanguage.includes(locale)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@
|
|||
<v-btn id="btn-download" icon @click.stop="download" class="ma-0 pa-0" v-if="isDownloadable">
|
||||
<v-icon size="small">get_app</v-icon>
|
||||
</v-btn>
|
||||
<v-btn id="btn-report" icon @click.stop="report" class="ma-0 pa-0">
|
||||
<v-icon size="small">$vuetify.icons.ic_report</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,7 @@
|
|||
<span>{{ $t("message.seen_by_count", seenBy.length) }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<BottomSheet
|
||||
:halfY="0.12"
|
||||
ref="seenByListBottomSheet"
|
||||
>
|
||||
<BottomSheet ref="seenByListBottomSheet">
|
||||
<v-list>
|
||||
<v-list-subheader class="text-uppercase"> {{ $t("message.seen_by") }}</v-list-subheader>
|
||||
<v-list-item v-for="(member, index) in seenBy" :key="index" class="text-left">
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
<template>
|
||||
<component :is="rootComponent" v-bind="{ ...$props, ...$attrs }">
|
||||
<div class="bubble">
|
||||
{{ inOut }}
|
||||
<div class="original-message" v-if="inReplyToText">
|
||||
<div class="original-message-sender">{{ inReplyToSender }}</div>
|
||||
<div class="original-message-text" v-html="linkify($$sanitize(inReplyToText))" />
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<ThumbnailView class="clickable" v-on:itemclick="onDownload" :item="attachment" />
|
||||
<ThumbnailView class="clickable" :item="attachment" />
|
||||
<span class="edit-marker" v-if="event?.replacingEventId()">{{ $t("message.edited") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -16,7 +15,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, Ref } from "vue";
|
||||
import { computed, inject } from "vue";
|
||||
import MessageIncoming from "./MessageIncoming.vue";
|
||||
import MessageOutgoing from "./MessageOutgoing.vue";
|
||||
import ThumbnailView from "../../file_mode/ThumbnailView.vue";
|
||||
|
|
@ -28,8 +27,6 @@ const { t } = useI18n();
|
|||
const $matrix: any = inject("globalMatrix");
|
||||
const $$sanitize: any = inject("globalSanitize");
|
||||
|
||||
const inOut: Ref<"in" | "out"> = ref("in");
|
||||
|
||||
const emits = defineEmits<{ (event: "download", value: KeanuEvent | undefined): void }>();
|
||||
const props = defineProps<MessageProps>();
|
||||
|
||||
|
|
@ -44,10 +41,6 @@ const { event, isIncoming, attachment, inReplyToText, inReplyToSender, linkify }
|
|||
const rootComponent = computed(() => {
|
||||
return isIncoming.value ? MessageIncoming : MessageOutgoing;
|
||||
});
|
||||
|
||||
const onDownload = () => {
|
||||
emits("download", event.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@
|
|||
v-bind="{ ...$props, ...$attrs }"
|
||||
>
|
||||
<div class="bubble image-bubble" ref="imageRef">
|
||||
<div class="bubble-inset" v-if="attachment?.proofHintFlags">
|
||||
<CCSummary :multiple="false" :flags="attachment.proofHintFlags ? [attachment.proofHintFlags] : []" />
|
||||
</div>
|
||||
<MediaIntervention :multiple="false" :media-intervention-flags="attachment?.mediaInterventionFlags ? [attachment.mediaInterventionFlags] : []" />
|
||||
|
||||
<ImageWithProgress v-if="attachment"
|
||||
:aspect-ratio="16 / 9"
|
||||
|
|
@ -36,7 +34,7 @@ import { MessageProps, useMessage } from "./useMessage";
|
|||
import { EventAttachment } from "../../../models/eventAttachment";
|
||||
import { useDisplay } from "vuetify";
|
||||
import Hammer from "hammerjs";
|
||||
import CCSummary from "../../content-credentials/CCSummary.vue";
|
||||
import MediaIntervention from "../../content-credentials/MediaIntervention.vue";
|
||||
|
||||
const { t } = useI18n()
|
||||
const $matrix: any = inject('globalMatrix');
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
<template>
|
||||
<component :is="rootComponent" ref="root" v-bind="{ ...$props, ...$attrs }" v-if="showMultiview">
|
||||
<div class="bubble">
|
||||
<div class="bubble-inset" v-if="showCCSummary">
|
||||
<CCSummary :multiple="items.length > 1" :flags="proofHintFlags" />
|
||||
</div>
|
||||
<MediaIntervention :multiple="items.length > 1" :mediaInterventionFlags="mediaInterventionFlags" />
|
||||
<div class="original-message bubble-inset" v-if="inReplyToText">
|
||||
<div class="original-message-sender">{{ inReplyToSender }}</div>
|
||||
<div class="original-message-text" v-html="linkify($$sanitize(inReplyToText))" />
|
||||
|
|
@ -62,13 +60,13 @@ import util, { ROOM_TYPE_CHANNEL, ROOM_TYPE_FILE_MODE } from "@/plugins/utils";
|
|||
import GalleryItemsView from "../../file_mode/GalleryItemsView.vue";
|
||||
import ThumbnailView from "../../file_mode/ThumbnailView.vue";
|
||||
import SwipeableThumbnailsView from "../channel/SwipeableThumbnailsView.vue";
|
||||
import CCSummary from "@/components/content-credentials/CCSummary.vue";
|
||||
import { computed, inject, onBeforeUnmount, ref, Ref, useTemplateRef, watch } from "vue";
|
||||
import { EventAttachment } from "../../../models/eventAttachment";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk";
|
||||
import { useLazyLoad } from "./useLazyLoad";
|
||||
import { ProofHintFlags } from "../../../models/proof";
|
||||
import { MediaInterventionFlags } from "../../../models/proof";
|
||||
import MediaIntervention from "../../content-credentials/MediaIntervention.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const $matrix: any = inject("globalMatrix");
|
||||
|
|
@ -163,6 +161,8 @@ const showMessageText = computed((): boolean => {
|
|||
return true;
|
||||
});
|
||||
|
||||
const isSinglePDF = computed(() => items.value.length === 1 && util.isFileTypePDF(items.value[0].event))
|
||||
|
||||
const showMultiview = computed((): boolean => {
|
||||
if (["m.image", "m.video"].includes(event.value?.getContent().msgtype ?? "")) {
|
||||
return true;
|
||||
|
|
@ -171,21 +171,15 @@ const showMultiview = computed((): boolean => {
|
|||
(isIncoming.value && props.room.displayType == ROOM_TYPE_FILE_MODE) ||
|
||||
items.value?.length > 1 ||
|
||||
(event.value && event.value.isRedacted()) ||
|
||||
(props.room.displayType == ROOM_TYPE_CHANNEL &&
|
||||
items.value.length == 1 &&
|
||||
util.isFileTypePDF(items.value[0].event)) ||
|
||||
messageText.value?.length > 0
|
||||
(props.room.displayType == ROOM_TYPE_CHANNEL && isSinglePDF.value) ||
|
||||
messageText.value?.length > 0 || isSinglePDF.value
|
||||
);
|
||||
});
|
||||
|
||||
const showCCSummary = computed(() => {
|
||||
return items.value?.some((i) => i.proofHintFlags !== undefined);
|
||||
});
|
||||
|
||||
const proofHintFlags = computed(() => {
|
||||
return items.value.reduce((res: ProofHintFlags[], item) => {
|
||||
if (item.proofHintFlags) {
|
||||
res.push(item.proofHintFlags);
|
||||
const mediaInterventionFlags = computed(() => {
|
||||
return items.value.reduce((res: MediaInterventionFlags[], item) => {
|
||||
if (item.mediaInterventionFlags) {
|
||||
res.push(item.mediaInterventionFlags);
|
||||
}
|
||||
return res;
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const useThumbnail = (source: KeanuEvent | File | undefined) => {
|
|||
} else if (source) {
|
||||
const file = source as File;
|
||||
isVideo.value = file.type.startsWith("video/");
|
||||
isImage.value = file.type.startsWith("image/");
|
||||
isImage.value = utils.isSupportedImageType(file.type);
|
||||
fileName.value = file.name;
|
||||
fileSize.value = prettyBytes(file.size);
|
||||
}
|
||||
|
|
@ -70,5 +70,6 @@ export const useThumbnail = (source: KeanuEvent | File | undefined) => {
|
|||
fileTypeIconClass,
|
||||
fileName,
|
||||
fileSize,
|
||||
isPDF
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -58,5 +58,9 @@ export default {
|
|||
this.$emit("close");
|
||||
this.$emit("unpin", {event:this.event});
|
||||
},
|
||||
report() {
|
||||
this.$emit("close");
|
||||
this.$emit("report", {event:this.event});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ import Vue3Sanitize from "vue-3-sanitize";
|
|||
import vuetify from './plugins/vuetify';
|
||||
import { Buffer } from 'buffer/'
|
||||
import { createApp, h } from 'vue';
|
||||
import { supportedImageTypes, supportedAvatarImageTypes } from '@/plugins/utils';
|
||||
|
||||
globalThis.Buffer = Buffer;
|
||||
|
||||
var defaultOptions = Vue3Sanitize.defaults;
|
||||
|
|
@ -25,6 +27,9 @@ defaultOptions.allowedTags = [];
|
|||
const app = createApp({
|
||||
render: () => h(App)
|
||||
});
|
||||
app.config.globalProperties.supportedImageTypes = supportedImageTypes;
|
||||
app.config.globalProperties.supportedAvatarImageTypes = supportedAvatarImageTypes;
|
||||
|
||||
app.use(Vue3Sanitize, defaultOptions);
|
||||
|
||||
app.config.productionTip = false
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ComputedRef, Ref } from "vue";
|
||||
import { Proof, ProofHintFlags } from "./proof";
|
||||
import { Proof, MediaMetadata } from "./proof";
|
||||
|
||||
export class UploadPromise<Type> {
|
||||
wrappedPromise: Promise<Type>;
|
||||
|
|
@ -53,13 +53,14 @@ export type Attachment = {
|
|||
status: "loading" | "loaded";
|
||||
file: File;
|
||||
dimensions?: { width: number; height: number };
|
||||
scaledFile?: File;
|
||||
scaledDimensions?: { width: number; height: number };
|
||||
useScaled: boolean;
|
||||
compressedFile?: File;
|
||||
compressedDimensions?: { width: number; height: number };
|
||||
useCompressed: boolean;
|
||||
src?: string;
|
||||
proof?: Proof;
|
||||
proofHintFlags?: ProofHintFlags;
|
||||
mediaMetadata?: MediaMetadata;
|
||||
thumbnail?: AttachmentThumbnail;
|
||||
detailsViewed: boolean;
|
||||
sendInfo: AttachmentSendInfo;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
EventAttachmentUrlType,
|
||||
KeanuEvent,
|
||||
KeanuEventExtension,
|
||||
KeanuRoom,
|
||||
} from "./eventAttachment";
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import { Counter, ModeOfOperation } from "aes-js";
|
||||
|
|
@ -18,8 +19,8 @@ import {
|
|||
import proofmode from "../plugins/proofmode";
|
||||
import imageResize from "image-resize";
|
||||
import { computed, ref, Ref, shallowReactive, unref } from "vue";
|
||||
import utils, { THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT, CLIENT_EVENT_PROOF_HINT } from "@/plugins/utils";
|
||||
import { extractProofHintFlags } from "./proof";
|
||||
import utils, { THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT, CLIENT_EVENT_MEDIA_INTERVENTION_FLAGS } from "@/plugins/utils";
|
||||
import { extractMediaMetadata } from "./proof";
|
||||
|
||||
export class AttachmentManager {
|
||||
matrixClient: MatrixClient;
|
||||
|
|
@ -48,15 +49,16 @@ export class AttachmentManager {
|
|||
.catch(() => {});
|
||||
}
|
||||
|
||||
public createUpload(room: Room) {
|
||||
public createUpload(room: KeanuRoom) {
|
||||
return createUploadBatch(this, room);
|
||||
}
|
||||
|
||||
public createAttachment(file: File, room: Room): Attachment {
|
||||
public createAttachment(file: File, room: KeanuRoom): Attachment {
|
||||
let a: Attachment = {
|
||||
status: "loading",
|
||||
file: file,
|
||||
useScaled: false,
|
||||
useCompressed: false,
|
||||
detailsViewed: false,
|
||||
sendInfo: {
|
||||
status: "initial",
|
||||
statusDate: 0,
|
||||
|
|
@ -73,9 +75,9 @@ export class AttachmentManager {
|
|||
return ra;
|
||||
}
|
||||
|
||||
private async prepareUpload(attachment: Attachment, room: Room): Promise<Attachment> {
|
||||
private async prepareUpload(attachment: Attachment, room: KeanuRoom): Promise<Attachment> {
|
||||
const file = attachment.file;
|
||||
if (file.type.startsWith("image/")) {
|
||||
if (utils.isSupportedImageType(file.type)) {
|
||||
let url = URL.createObjectURL(file);
|
||||
attachment.src = url;
|
||||
if (attachment.src) {
|
||||
|
|
@ -104,11 +106,11 @@ export class AttachmentManager {
|
|||
height: newHeight,
|
||||
outputType: "blob",
|
||||
});
|
||||
attachment.scaledFile = new File([compressedImg as BlobPart], file.name, {
|
||||
attachment.compressedFile = new File([compressedImg as BlobPart], file.name, {
|
||||
type: "image/webp",
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
attachment.scaledDimensions = {
|
||||
attachment.compressedDimensions = {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
};
|
||||
|
|
@ -118,24 +120,33 @@ export class AttachmentManager {
|
|||
}
|
||||
try {
|
||||
attachment.proof = await proofmode.proofCheckFile(file);
|
||||
attachment.proofHintFlags = extractProofHintFlags(attachment.proof);
|
||||
attachment.mediaMetadata = extractMediaMetadata(attachment.proof);
|
||||
|
||||
// Default to scaled version if the image does not contain Content Credentials
|
||||
//
|
||||
const isDirectRoom = (room: Room) => {
|
||||
// TODO - Use the is_direct accountData flag (m.direct). WE (as the client)
|
||||
// apprently need to set this...
|
||||
if (room && room.getJoinRule() == "invite" && room.getMembers().length == 2) {
|
||||
if (room && room.getJoinRule() == "invite" && room.getInvitedAndJoinedMemberCount() == 2) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
attachment.useScaled =
|
||||
attachment.scaledFile !== undefined &&
|
||||
(attachment.proof === undefined ||
|
||||
!isDirectRoom(room) ||
|
||||
attachment.proof.integrity === undefined ||
|
||||
attachment.proof.integrity.c2pa === undefined);
|
||||
const isChannel = room.displayType == "im.keanu.room_type_channel";
|
||||
const isFileDrop = room.displayType == "im.keanu.room_type_file";
|
||||
|
||||
let useOriginal = false;
|
||||
if (isChannel) {
|
||||
useOriginal = false;
|
||||
} else if (isFileDrop) {
|
||||
useOriginal = true;
|
||||
} else {
|
||||
if (isDirectRoom(room) && attachment.proof?.integrity?.c2pa !== undefined) {
|
||||
useOriginal = true;
|
||||
}
|
||||
}
|
||||
|
||||
attachment.useCompressed = attachment.compressedFile !== undefined && !useOriginal;
|
||||
} catch (error) {
|
||||
console.error("Failed to get content credentials: " + error);
|
||||
}
|
||||
|
|
@ -220,7 +231,7 @@ export class AttachmentManager {
|
|||
|
||||
const fileSize = this.getSrcFileSize(event);
|
||||
|
||||
let proofHintFlags = event.getContent()[CLIENT_EVENT_PROOF_HINT];
|
||||
let mediaInterventionFlags = event.getContent()[CLIENT_EVENT_MEDIA_INTERVENTION_FLAGS];
|
||||
|
||||
const attachment: EventAttachment = {
|
||||
event: event,
|
||||
|
|
@ -229,7 +240,8 @@ export class AttachmentManager {
|
|||
srcProgress: -1,
|
||||
thumbnailProgress: -1,
|
||||
autoDownloadable: fileSize <= this.maxSizeAutoDownloads,
|
||||
proofHintFlags: proofHintFlags ? JSON.parse(proofHintFlags) : proofHintFlags,
|
||||
mediaInterventionFlags: mediaInterventionFlags ? JSON.parse(mediaInterventionFlags) : undefined,
|
||||
mediaMetadata: undefined,
|
||||
proof: undefined,
|
||||
loadSrc: () => Promise.reject("Not implemented"),
|
||||
loadThumbnail: () => Promise.reject("Not implemented"),
|
||||
|
|
@ -427,7 +439,7 @@ export class AttachmentManager {
|
|||
}
|
||||
}
|
||||
|
||||
export const createUploadBatch = (manager: AttachmentManager | null, room: Room | null): AttachmentBatch => {
|
||||
export const createUploadBatch = (manager: AttachmentManager | null, room: KeanuRoom | null): AttachmentBatch => {
|
||||
const matrixClient = manager?.matrixClient;
|
||||
const maxSizeUploads = manager?.maxSizeUploads ?? 0;
|
||||
|
||||
|
|
@ -489,7 +501,7 @@ export const createUploadBatch = (manager: AttachmentManager | null, room: Room
|
|||
};
|
||||
|
||||
const isTooLarge = (attachment: Attachment) => {
|
||||
const file = attachment.scaledFile && attachment.useScaled ? attachment.scaledFile : attachment.file;
|
||||
const file = attachment.compressedFile && attachment.useCompressed ? attachment.compressedFile : attachment.file;
|
||||
return file.size > maxSizeUploads;
|
||||
};
|
||||
|
||||
|
|
@ -607,11 +619,11 @@ export const createUploadBatch = (manager: AttachmentManager | null, room: Room
|
|||
item.sendInfo.status = "sending";
|
||||
|
||||
let file = (() => {
|
||||
if (attachment.scaledFile && attachment.useScaled) {
|
||||
// Send scaled version of image instead!
|
||||
return attachment.scaledFile;
|
||||
if (attachment.compressedFile && attachment.useCompressed) {
|
||||
// Send compressed!
|
||||
return attachment.compressedFile;
|
||||
} else {
|
||||
// Send actual file image when not scaled!
|
||||
// Send original
|
||||
return attachment.file;
|
||||
}
|
||||
})();
|
||||
|
|
@ -632,7 +644,7 @@ export const createUploadBatch = (manager: AttachmentManager | null, room: Room
|
|||
eventId,
|
||||
attachment.dimensions,
|
||||
attachment.thumbnail,
|
||||
attachment.proofHintFlags
|
||||
attachment.mediaMetadata
|
||||
)
|
||||
.then((mediaEventId: string) => {
|
||||
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { MatrixEvent, Room } from "matrix-js-sdk";
|
||||
import { AttachmentBatch } from "./attachment";
|
||||
import { Proof, ProofHintFlags } from "./proof";
|
||||
import { Proof, MediaMetadata, MediaInterventionFlags } from "./proof";
|
||||
|
||||
export type KeanuEventExtension = {
|
||||
isMxThread?: boolean;
|
||||
|
|
@ -28,7 +28,8 @@ export type EventAttachment = {
|
|||
thumbnailPromise?: Promise<EventAttachmentUrlData>;
|
||||
autoDownloadable: boolean;
|
||||
proof?: Proof;
|
||||
proofHintFlags?: ProofHintFlags;
|
||||
mediaInterventionFlags?: MediaInterventionFlags;
|
||||
mediaMetadata?: MediaMetadata;
|
||||
loadSrc: () => Promise<EventAttachmentUrlData>;
|
||||
loadThumbnail: () => Promise<EventAttachmentUrlData>;
|
||||
loadBlob: () => Promise<{data: Blob}>;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import utils from "@/plugins/utils";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export type AIInferenceResult = {
|
||||
aiGenerated: boolean;
|
||||
aiProbability: number;
|
||||
|
|
@ -59,21 +62,38 @@ export type Proof = {
|
|||
ai?: { inferenceResult?: AIInferenceResult };
|
||||
};
|
||||
|
||||
export type ProofHintFlagsGenerator = "unknown" | "camera" | "screenshot" | "ai";
|
||||
export type ProofHintFlagsGeneratorSource = "c2pa" | "exif" | "metadata";
|
||||
export type ProofHintFlagsEditor = "unknown" | "manual" | "ai";
|
||||
export type MediaMetadataGenerator = "unknown" | "camera" | "screenshot" | "ai";
|
||||
export type MediaMetadataPropertySource = "c2pa" | "exif" | "metadata";
|
||||
|
||||
export type ProofHintFlagsEdit = {
|
||||
editor: ProofHintFlagsEditor;
|
||||
export type MediaMetadataEdit = {
|
||||
editor: string;
|
||||
date?: Date;
|
||||
}
|
||||
};
|
||||
|
||||
export type ProofHintFlags = {
|
||||
export type MediaMetadataLocation = {
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
source: MediaMetadataPropertySource;
|
||||
};
|
||||
|
||||
export type MediaInterventionFlags = {
|
||||
creationDate?: Date;
|
||||
generator?: MediaMetadataGenerator;
|
||||
modified?: boolean;
|
||||
containsC2PA?: boolean;
|
||||
containsEXIF?: boolean;
|
||||
};
|
||||
|
||||
export type MediaMetadata = {
|
||||
device?: string;
|
||||
creationDate?: Date;
|
||||
generator?: ProofHintFlagsGenerator;
|
||||
generatorSource?: ProofHintFlagsGeneratorSource;
|
||||
edits?: ProofHintFlagsEdit[];
|
||||
creationDateSource?: MediaMetadataPropertySource;
|
||||
generator?: MediaMetadataGenerator;
|
||||
generatorSource?: MediaMetadataPropertySource;
|
||||
edits?: MediaMetadataEdit[];
|
||||
containsC2PA?: boolean;
|
||||
containsEXIF?: boolean;
|
||||
location?: MediaMetadataLocation;
|
||||
};
|
||||
|
||||
type FlagMatchRule = {
|
||||
|
|
@ -82,9 +102,14 @@ type FlagMatchRule = {
|
|||
description: string;
|
||||
};
|
||||
|
||||
type FlagMatchRuleValue = {
|
||||
type FlagMatchRulePathSegment = {
|
||||
object: any;
|
||||
path: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type FlagMatchRuleValue = {
|
||||
path: FlagMatchRulePathSegment[];
|
||||
value: string | object;
|
||||
};
|
||||
|
||||
type FlagMatchInfo = {
|
||||
|
|
@ -93,51 +118,118 @@ type FlagMatchInfo = {
|
|||
re: string;
|
||||
};
|
||||
|
||||
const ruleScreenshotC2PA = (): FlagMatchRule[] => {
|
||||
type FlagValue = {
|
||||
path: string;
|
||||
transform?: (value: any, match: FlagMatchRuleValue) => any;
|
||||
matches?: string[];
|
||||
description?: string; // Not currently used
|
||||
};
|
||||
|
||||
const pathsC2PASource = (): FlagValue[] => {
|
||||
return [
|
||||
{
|
||||
field:
|
||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/digitalSourceType",
|
||||
match: [C2PASourceTypeScreenCapture],
|
||||
description: "Screen capture",
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:Make",
|
||||
transform: getExifMakeModelPrefixed,
|
||||
},
|
||||
{
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:Model",
|
||||
transform: getExifMakeModelPrefixed,
|
||||
},
|
||||
{
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/softwareAgent/name",
|
||||
},
|
||||
{
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/softwareAgent",
|
||||
},
|
||||
{
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../../claim_generator",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const ruleScreenshotMeta = (): FlagMatchRule[] => {
|
||||
const pathsExifSource = (): FlagValue[] => {
|
||||
return [
|
||||
{ path: "integrity/exif/Make", transform: getExifMakeModelNoPrefix },
|
||||
{ path: "integrity/exif/Model", transform: getExifMakeModelNoPrefix },
|
||||
];
|
||||
};
|
||||
|
||||
const pathsC2PACreationDate = (): FlagValue[] => {
|
||||
return [
|
||||
{
|
||||
field:
|
||||
"name",
|
||||
match: ["screenshot"],
|
||||
description: "Screen capture",
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/when",
|
||||
},
|
||||
{
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../metadata/dateTime",
|
||||
},
|
||||
{
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/../../../signature_info/time",
|
||||
},
|
||||
{
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:DateTimeOriginal",
|
||||
transform: (value, match) => {
|
||||
// Check if we have a timestamp offset
|
||||
let offset = extractFlagValues("../exif:OffsetTimeOriginal", match.path)?.at(0)?.value;
|
||||
return dayjs(Date.parse(value + (offset ? offset : ""))).format("lll");
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const ruleCamera = (): FlagMatchRule[] => {
|
||||
const pathsC2PACamera = (): FlagValue[] => {
|
||||
return [
|
||||
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:SceneType", matches: ["^1$", "^directly photographed image$"] },
|
||||
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:LensMake" },
|
||||
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:LensModel" },
|
||||
];
|
||||
};
|
||||
|
||||
const pathsExifCamera = (): FlagValue[] => {
|
||||
return [
|
||||
{ path: "integrity/exif/SceneType", matches: ["^1$", "^directly photographed image$"] },
|
||||
{ path: "integrity/exif/LensMake" },
|
||||
{ path: "integrity/exif/LensModel" },
|
||||
];
|
||||
};
|
||||
|
||||
const pathsExifScreenshot = (): FlagValue[] => {
|
||||
return [
|
||||
{
|
||||
field:
|
||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/digitalSourceType",
|
||||
match: [C2PASourceTypeDigitalCapture, C2PASourceTypeComputationalCapture, C2PASourceTypeCompositeCapture],
|
||||
description: "Captured by camera",
|
||||
path: "integrity/exif/UserComment",
|
||||
transform: exifStringTransform,
|
||||
matches: ["^Screenshot$"],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const ruleAiGenerated = (): FlagMatchRule[] => {
|
||||
return [
|
||||
const pathsMetaScreenshot = (): FlagValue[] => {
|
||||
return [
|
||||
{
|
||||
field:
|
||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/digitalSourceType",
|
||||
match: [C2PASourceTypeTrainedAlgorithmicMedia, C2PASourceTypeCompositeWithTrainedAlgorithmicMedia],
|
||||
description: "Generated by AI",
|
||||
},
|
||||
path: "name",
|
||||
matches: ["screenshot"],
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const ruleAiMeta = (): FlagMatchRule[] => {
|
||||
const pathsC2PALocation = (): FlagValue[] => {
|
||||
return [
|
||||
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLatitude" },
|
||||
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLongitude" },
|
||||
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLatitudeRef" },
|
||||
{ path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/GPSLongitudeRef" },
|
||||
];
|
||||
};
|
||||
|
||||
const pathsExifLocation = (): FlagValue[] => {
|
||||
return [
|
||||
{ path: "integrity/exif/GPSLatitude" },
|
||||
{ path: "integrity/exif/GPSLongitude" },
|
||||
{ path: "integrity/exif/GPSLatitudeRef" },
|
||||
{ path: "integrity/exif/GPSLongitudeRef" },
|
||||
];
|
||||
};
|
||||
|
||||
const pathsMetaAI = (): FlagValue[] => {
|
||||
const knownAIServices = [
|
||||
"ChatGPT",
|
||||
"OpenAI-API",
|
||||
|
|
@ -145,79 +237,171 @@ const ruleAiMeta = (): FlagMatchRule[] => {
|
|||
"RunwayML",
|
||||
"Runway AI",
|
||||
"Google AI",
|
||||
"Stable Diffusion",
|
||||
"Stable Diffusion"
|
||||
];
|
||||
return [
|
||||
{ field: "name", match: ["^DALL_E_", "^Gen-3"], description: "File name" },
|
||||
{ path: "name", matches: ["^DALL_E_", "^Gen-3"], description: "File name" },
|
||||
{
|
||||
field: "integrity/c2pa/manifest_info/manifests[]/claim_generator",
|
||||
match: knownAIServices,
|
||||
path: "integrity/c2pa/manifest_info/manifests[]/claim_generator",
|
||||
matches: knownAIServices,
|
||||
description: "C2PA claim generator",
|
||||
},
|
||||
{ field: "iptc/Credit", match: knownAIServices, description: "IPTC Credit" },
|
||||
{ field: "iptc/Provider", match: knownAIServices, description: "IPTC Provider" },
|
||||
{ field: "iptc/ImageSupplier[]", match: knownAIServices, description: "IPTC ImageSupplier" },
|
||||
{ field: "iptc/ImageCreator[]", match: knownAIServices, description: "IPTC ImageCreator" },
|
||||
{ path: "iptc/Credit", matches: knownAIServices, description: "IPTC Credit" },
|
||||
{ path: "iptc/Provider", matches: knownAIServices, description: "IPTC Provider" },
|
||||
{ path: "iptc/ImageSupplier[]", matches: knownAIServices, description: "IPTC ImageSupplier" },
|
||||
{ path: "iptc/ImageCreator[]", matches: knownAIServices, description: "IPTC ImageCreator" },
|
||||
];
|
||||
};
|
||||
|
||||
const matchFlag = (rules: FlagMatchRule[], file: any) => {
|
||||
let result = false;
|
||||
let resultInfo: FlagMatchInfo[] = [];
|
||||
for (let rule of rules) {
|
||||
try {
|
||||
const re: RegExp[] = (!Array.isArray(rule.match) ? [rule.match] : rule.match).map((m) => new RegExp(m, "gi"));
|
||||
const values = extractFlagValues(rule.field, file);
|
||||
values.forEach((v) => {
|
||||
re.forEach((r) => {
|
||||
if (r.test(v.value)) {
|
||||
result = true;
|
||||
resultInfo.push({ field: v.path, value: v.value, re: r.source });
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Invalid RE", e);
|
||||
}
|
||||
}
|
||||
return { result: result, matches: resultInfo };
|
||||
const getExifValue = (val: string): string => {
|
||||
return val.replace(/^"(.+(?="$))"$/, "$1");
|
||||
};
|
||||
|
||||
const extractFlagValues = (flagPath: string, file: any): FlagMatchRuleValue[] => {
|
||||
const getValues = (
|
||||
path: string[],
|
||||
objectPath: any[],
|
||||
actualPath: string,
|
||||
o: any
|
||||
): FlagMatchRuleValue[] | undefined => {
|
||||
if (path.length == 0 || o == undefined) return undefined;
|
||||
let part = path[0];
|
||||
const lastBracket = part.lastIndexOf("[");
|
||||
if (part === "..") {
|
||||
return getValues(path.slice(1), objectPath.slice(1), actualPath + "/..", objectPath[0]);
|
||||
const toDegrees = (dms: string | undefined, direction: string) => {
|
||||
if (!dms || dms.length == 0) return undefined;
|
||||
var parts = dms.split(/deg|min|sec/);
|
||||
var d = parts[0];
|
||||
var m = parts[1];
|
||||
var s = parts[2];
|
||||
var deg = (Number(d) + Number(m) / 60 + Number(s) / 3600).toFixed(6);
|
||||
if (direction == "S" || direction == "W") {
|
||||
deg = "-" + deg;
|
||||
}
|
||||
if (part.endsWith("]") && lastBracket > 0) {
|
||||
const optionalConstraint = part.substring(lastBracket + 1, part.length - 1);
|
||||
part = part.substring(0, lastBracket);
|
||||
if (o[part] != undefined) {
|
||||
let opart: any[] = o[part];
|
||||
if (!Array.isArray(opart)) {
|
||||
opart = Object.values(opart) ?? [];
|
||||
return deg;
|
||||
};
|
||||
|
||||
const pathsExifCreationDate = (): FlagValue[] => {
|
||||
return [
|
||||
{
|
||||
path: "integrity/exif/DateTimeOriginal",
|
||||
transform: (value, match) => {
|
||||
let dateTimeOriginal = getExifValue(value);
|
||||
// Check if we have a timestamp offset
|
||||
let offset = extractFlagValues("../OffsetTimeOriginal", match.path)?.at(0)?.value as string;
|
||||
return dayjs(Date.parse(dateTimeOriginal + (offset ? getExifValue(offset) : ""))).format("lll");
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getExifMakeModelNoPrefix = (ignoredvalue: any, match: FlagMatchRuleValue): string => {
|
||||
return getExifMakeModel(match, "");
|
||||
};
|
||||
|
||||
const getExifMakeModelPrefixed = (ignoredvalue: any, match: FlagMatchRuleValue): string => {
|
||||
return getExifMakeModel(match, "exif:");
|
||||
};
|
||||
|
||||
const exifStringTransform = (value: any, match: FlagMatchRuleValue): string => {
|
||||
try {
|
||||
const s = value as string;
|
||||
if (s) {
|
||||
if (s.toLowerCase().startsWith("0x")) {
|
||||
if (s.toLowerCase().startsWith("0x554e49434f444500")) {
|
||||
// Unicode
|
||||
const buffer = Buffer.from(s.substring(18), "hex");
|
||||
return buffer.toString("utf-8");
|
||||
// } else if (s.toLowerCase().startsWith("0x4a49530000000000")) {
|
||||
// // JIS
|
||||
// const buffer = Buffer.from(s.substring(18), 'hex');
|
||||
// return buffer.toString("ascii");
|
||||
} else if (
|
||||
s.toLowerCase().startsWith("0x4153434949000000") ||
|
||||
s.toLowerCase().startsWith("0x0000000000000000")
|
||||
) {
|
||||
// Ascii
|
||||
const buffer = Buffer.from(s.substring(18), "hex");
|
||||
console.log(buffer.toString("ascii"));
|
||||
return buffer.toString("ascii");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getExifMakeModel = (match: FlagMatchRuleValue, prefix: string): string => {
|
||||
// Make and model
|
||||
let makeAndModel = "";
|
||||
const make = extractFlagValues(`../${prefix}Make`, match.path)?.at(0)?.value as string;
|
||||
const model = extractFlagValues(`../${prefix}Model`, match.path)?.at(0)?.value as string;
|
||||
if (make) {
|
||||
makeAndModel += getExifValue(make);
|
||||
}
|
||||
if (model) {
|
||||
if (makeAndModel.length > 0) {
|
||||
makeAndModel += ", ";
|
||||
}
|
||||
makeAndModel += getExifValue(model);
|
||||
}
|
||||
return makeAndModel;
|
||||
};
|
||||
|
||||
|
||||
const extractFlagValues = (flagPath: string, path: FlagMatchRulePathSegment[]): FlagMatchRuleValue[] => {
|
||||
const getValues = (keys: string[], path: FlagMatchRulePathSegment[]): FlagMatchRuleValue[] | undefined => {
|
||||
if (keys.length == 0 || path.length == 0) return undefined;
|
||||
|
||||
const o = path[0].object;
|
||||
let key = keys[0];
|
||||
|
||||
if (key === "..") {
|
||||
return getValues(keys.slice(1), path.slice(1));
|
||||
}
|
||||
|
||||
const lastBracket = key.lastIndexOf("[");
|
||||
const hasBracket = key.endsWith("]") && lastBracket > 0;
|
||||
|
||||
let optionalConstraint: String | undefined = undefined;
|
||||
|
||||
if (hasBracket) {
|
||||
optionalConstraint = key.substring(lastBracket + 1, key.length - 1);
|
||||
key = key.substring(0, lastBracket);
|
||||
}
|
||||
|
||||
let nextObject = o[key];
|
||||
let omatches: any[] = nextObject
|
||||
? Array.isArray(nextObject)
|
||||
? nextObject
|
||||
: hasBracket
|
||||
? Object.values(nextObject)
|
||||
: [nextObject]
|
||||
: [];
|
||||
|
||||
// Any constraints controlling what array object(s) to consider?
|
||||
if (optionalConstraint) {
|
||||
if (optionalConstraint.startsWith("!!")) {
|
||||
// Ignore this path in the tree if ANY of the values contain the constraint match.
|
||||
const [prop, val] = optionalConstraint.substring(2).split("=");
|
||||
let valarray = val.split("|");
|
||||
if (omatches.some((item) => valarray.includes(item[prop]))) {
|
||||
omatches = [];
|
||||
}
|
||||
} else if (optionalConstraint.startsWith("!")) {
|
||||
const [prop, val] = optionalConstraint.substring(1).split("=");
|
||||
let valarray = val.split("|");
|
||||
omatches = omatches.filter((m) => {
|
||||
return valarray.includes(m[prop]);
|
||||
});
|
||||
} else {
|
||||
const [prop, val] = optionalConstraint.split("=");
|
||||
opart = opart.filter((item) => item[prop] === val);
|
||||
let valarray = val.split("|");
|
||||
omatches = omatches.filter((m) => {
|
||||
return valarray.includes(m[prop]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (path.length == 1) {
|
||||
let strings = opart as string[];
|
||||
return strings.map((s, i) => ({ path: actualPath + "/" + part + "[" + i + "]", value: s }));
|
||||
if (omatches.length > 0) {
|
||||
if (keys.length == 1) {
|
||||
return omatches.map((oin, i) => {
|
||||
return { value: oin, path: [{ object: oin, path: key + (omatches.length > 1 ? `[${i}]` : "") }, ...path] };
|
||||
});
|
||||
} else if (omatches.length == 1) {
|
||||
return getValues(keys.slice(1), [{ object: omatches[0], path: key }, ...path]);
|
||||
} else {
|
||||
return opart.reduce((res: FlagMatchRuleValue[] | undefined, o: any, i: number) => {
|
||||
const newObjectPaths = [o, ...objectPath];
|
||||
let matches = getValues(path.slice(1), newObjectPaths, actualPath + "/" + part + "[" + i + "]", o);
|
||||
return omatches.reduce((res: FlagMatchRuleValue[] | undefined, oin: any, i: number) => {
|
||||
let matches = getValues(keys.slice(1), [{ object: oin, path: key + "[" + i + "]" }, ...path]);
|
||||
if (matches) {
|
||||
const r2 = res || [];
|
||||
r2.push(...matches);
|
||||
|
|
@ -229,34 +413,88 @@ const extractFlagValues = (flagPath: string, file: any): FlagMatchRuleValue[] =>
|
|||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
if (o[part] != undefined) {
|
||||
if (path.length == 1) {
|
||||
return [{ path: actualPath + "/" + part, value: o[part] }];
|
||||
} else {
|
||||
const newObjectPaths = [o, ...objectPath];
|
||||
return getValues(path.slice(1), newObjectPaths, actualPath + "/" + part, o[part]);
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result: FlagMatchRuleValue[] = [];
|
||||
try {
|
||||
let parts = flagPath.split("/");
|
||||
result = getValues(parts, [], "", file) ?? [];
|
||||
let keys = flagPath.split("/");
|
||||
result = getValues(keys, path) ?? [];
|
||||
} catch (e) {
|
||||
console.error("Invalid RE", e);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const extractProofHintFlags = (proof?: Proof): ProofHintFlags | undefined => {
|
||||
const getFirstWithData = (flagValues: FlagValue[], path: FlagMatchRulePathSegment[]): string | undefined => {
|
||||
for (let idx = 0; idx < flagValues.length; idx++) {
|
||||
const result = extractFlagValues(flagValues[idx].path, path);
|
||||
if (result.length > 0) {
|
||||
try {
|
||||
let first = result[0];
|
||||
let val: string | undefined = first.value as string;
|
||||
let transform = flagValues[idx].transform;
|
||||
if (val && transform) {
|
||||
val = transform(val, first);
|
||||
}
|
||||
if (val && flagValues[idx].matches) {
|
||||
if (!flagValues[idx].matches!.some((m) => {
|
||||
const re = new RegExp(m, "gi");
|
||||
return re.test(val!);
|
||||
})) {
|
||||
val = undefined;
|
||||
}
|
||||
}
|
||||
if (val) {
|
||||
return val;
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getFirstWithDataAsDate = (flagValues: FlagValue[], path: FlagMatchRulePathSegment[]): Date | undefined => {
|
||||
let val = getFirstWithData(flagValues, path);
|
||||
if (val) {
|
||||
try {
|
||||
let date = new Date(Date.parse(val));
|
||||
if (isNaN(date.valueOf())) {
|
||||
// Try EXIF format
|
||||
date = utils.parseExifDate(val);
|
||||
}
|
||||
return date;
|
||||
} catch (error) {}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getMultiple = (flagValues: FlagValue[], path: FlagMatchRulePathSegment[]): (string | undefined)[] => {
|
||||
let results: (string | undefined)[] = new Array(flagValues.length);
|
||||
for (let idx = 0; idx < flagValues.length; idx++) {
|
||||
const result = extractFlagValues(flagValues[idx].path, path);
|
||||
if (result.length > 0) {
|
||||
results[idx] = result[0].value as string;
|
||||
} else {
|
||||
results[idx] = undefined;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
export const mediaMetadataToMediaInterventionFlags = (mediaMetadata: MediaMetadata): MediaInterventionFlags => {
|
||||
return {
|
||||
creationDate: mediaMetadata.creationDate,
|
||||
generator: mediaMetadata.generator,
|
||||
modified: mediaMetadata.edits && mediaMetadata.edits.length > 0,
|
||||
containsC2PA: mediaMetadata.containsC2PA,
|
||||
containsEXIF: mediaMetadata.containsEXIF,
|
||||
};
|
||||
};
|
||||
|
||||
export const extractMediaMetadata = (proof?: Proof): MediaMetadata | undefined => {
|
||||
if (!proof) return undefined;
|
||||
|
||||
let edits: ProofHintFlagsEdit[] | undefined = undefined;
|
||||
let edits: MediaMetadataEdit[] | undefined = undefined;
|
||||
let valid = false;
|
||||
|
||||
try {
|
||||
|
|
@ -265,49 +503,127 @@ export const extractProofHintFlags = (proof?: Proof): ProofHintFlags | undefined
|
|||
valid = results.failure.length == 0 && results.success.length > 0;
|
||||
}
|
||||
|
||||
const source = extractFlagValues(
|
||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/softwareAgent",
|
||||
proof
|
||||
);
|
||||
const rootMatchPath = [{ object: proof, path: "" }];
|
||||
|
||||
const dateCreated = extractFlagValues(
|
||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/../../../../signature_info/time",
|
||||
proof
|
||||
);
|
||||
let date: Date | undefined = undefined;
|
||||
if (dateCreated && dateCreated.length == 1) {
|
||||
try {
|
||||
date = new Date(Date.parse(dateCreated[0].value));
|
||||
} catch (error) {}
|
||||
let source: string | undefined = undefined;
|
||||
let dateCreated: Date | undefined = undefined;
|
||||
let dateCreatedSource: MediaMetadataPropertySource | undefined = undefined;
|
||||
|
||||
source = getFirstWithData(pathsC2PASource(), rootMatchPath);
|
||||
if (!source) {
|
||||
source = getFirstWithData(pathsExifSource(), rootMatchPath);
|
||||
}
|
||||
console.log("DATE CREATED", date);
|
||||
|
||||
let generator: ProofHintFlagsGenerator = matchFlag(ruleAiGenerated(), proof).result ? "ai" : matchFlag(ruleScreenshotC2PA(), proof).result ? "screenshot" : matchFlag(ruleCamera(), proof).result ? "camera" : "unknown";
|
||||
let generatorSource: ProofHintFlagsGeneratorSource | undefined = undefined;
|
||||
|
||||
if (generator !== "unknown" && valid) {
|
||||
generatorSource = "c2pa";
|
||||
dateCreated = getFirstWithDataAsDate(pathsC2PACreationDate(), rootMatchPath);
|
||||
if (dateCreated) {
|
||||
dateCreatedSource = "c2pa";
|
||||
} else {
|
||||
if (matchFlag(ruleScreenshotMeta(), proof).result) {
|
||||
dateCreated = getFirstWithDataAsDate(pathsExifCreationDate(), rootMatchPath);
|
||||
if (dateCreated) {
|
||||
dateCreatedSource = "exif";
|
||||
}
|
||||
}
|
||||
|
||||
let generator: MediaMetadataGenerator = "unknown";
|
||||
let generatorSource: MediaMetadataPropertySource | undefined = undefined;
|
||||
|
||||
let digitalSourceType = extractFlagValues(
|
||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/digitalSourceType",
|
||||
rootMatchPath
|
||||
)?.at(0)?.value as string;
|
||||
if ([C2PASourceTypeScreenCapture].includes(digitalSourceType)) {
|
||||
generator = "screenshot";
|
||||
generatorSource = "c2pa";
|
||||
} else if (
|
||||
[C2PASourceTypeDigitalCapture, C2PASourceTypeComputationalCapture, C2PASourceTypeCompositeCapture].includes(
|
||||
digitalSourceType
|
||||
)
|
||||
) {
|
||||
generator = "camera";
|
||||
generatorSource = "c2pa";
|
||||
} else if (
|
||||
[C2PASourceTypeTrainedAlgorithmicMedia, C2PASourceTypeCompositeWithTrainedAlgorithmicMedia].includes(
|
||||
digitalSourceType
|
||||
)
|
||||
) {
|
||||
generator = "ai";
|
||||
generatorSource = "c2pa";
|
||||
} else if (getFirstWithData(pathsC2PACamera(), rootMatchPath)) {
|
||||
generator = "camera";
|
||||
generatorSource = "c2pa";
|
||||
} else if (getFirstWithData(pathsExifCamera(), rootMatchPath)) {
|
||||
generator = "camera";
|
||||
generatorSource = "exif";
|
||||
} else if (getFirstWithData(pathsExifScreenshot(), rootMatchPath)) {
|
||||
generator = "screenshot";
|
||||
generatorSource = "exif";
|
||||
} else if (getFirstWithData(pathsMetaScreenshot(), rootMatchPath)) {
|
||||
generator = "screenshot";
|
||||
generatorSource = "metadata";
|
||||
} else if (matchFlag(ruleAiMeta(), proof).result) {
|
||||
} else if (getFirstWithData(pathsMetaAI(), rootMatchPath)) {
|
||||
generator = "ai";
|
||||
generatorSource = "metadata";
|
||||
}
|
||||
|
||||
console.error("PROOF", proof);
|
||||
|
||||
const c2paEdits = extractFlagValues(
|
||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[!!action=c2pa.created]",
|
||||
rootMatchPath
|
||||
);
|
||||
if (c2paEdits.length > 0) {
|
||||
edits = c2paEdits.map((edit) => {
|
||||
return {
|
||||
editor:
|
||||
getFirstWithData(
|
||||
[{ path: "softwareAgent/name" }, { path: "softwareAgent" }, { path: "../../../claim_generator" }],
|
||||
edit.path
|
||||
) ?? "",
|
||||
date: getFirstWithDataAsDate(
|
||||
[{ path: "when" }, { path: "../../metadata/dateTime" }, { path: "../../../signature_info/time" }],
|
||||
edit.path
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Location
|
||||
let location: MediaMetadataLocation | undefined = undefined;
|
||||
let locationSource: MediaMetadataPropertySource = "c2pa"
|
||||
let [lat, lon, latref, lonref] = getMultiple(pathsC2PALocation(), rootMatchPath);
|
||||
if (!lat || !lon) {
|
||||
[lat, lon, latref, lonref] = getMultiple(pathsExifLocation(), rootMatchPath);
|
||||
locationSource = "exif"
|
||||
}
|
||||
if (lat && lon) {
|
||||
try {
|
||||
const latitude = toDegrees(getExifValue(lat), getExifValue(latref ?? ""));
|
||||
const longitude = toDegrees(getExifValue(lon), getExifValue(lonref ?? ""));
|
||||
if (latitude && longitude && latitude.length > 0 && longitude.length > 0) {
|
||||
location = {
|
||||
latitude,
|
||||
longitude,
|
||||
source: "exif",
|
||||
};
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
// Do we have any data? Else, return "undefined", we don't just want to send an object with all defaults.
|
||||
if (source.length === 0 && dateCreated.length === 0 && generator === "unknown") {
|
||||
if (source === undefined && dateCreated === undefined && generator === "unknown" && (!edits || edits.length == 0)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const flags: ProofHintFlags = {
|
||||
device: source && source.length == 1 ? source[0].value : undefined,
|
||||
creationDate: date,
|
||||
const flags: MediaMetadata = {
|
||||
device: source,
|
||||
creationDate: dateCreated,
|
||||
creationDateSource: dateCreatedSource,
|
||||
generator: generator,
|
||||
generatorSource: generatorSource,
|
||||
edits: edits,
|
||||
containsC2PA: proof.integrity?.c2pa !== undefined,
|
||||
containsEXIF: proof.integrity?.exif !== undefined,
|
||||
location: location,
|
||||
};
|
||||
return flags;
|
||||
} catch (error) {}
|
||||
|
|
|
|||
152
src/plugins/png.ts
Normal file
152
src/plugins/png.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
var metadata: any = {};
|
||||
|
||||
type PNGChunk = {
|
||||
size: number;
|
||||
type: string;
|
||||
data: Uint8Array;
|
||||
crc: number;
|
||||
}
|
||||
|
||||
metadata.PNG_SIG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
metadata.decoder = new TextDecoder("ascii");
|
||||
metadata.encoder = new TextEncoder();
|
||||
|
||||
metadata.compare = (buf1: Uint8Array, buf2: Uint8Array) => {
|
||||
if (buf1.byteLength != buf2.byteLength) return false;
|
||||
var dv1 = new Int8Array(buf1);
|
||||
var dv2 = new Int8Array(buf2);
|
||||
for (var i = 0; i != buf1.byteLength; i++) {
|
||||
if (dv1[i] != dv2[i]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// check PNG signature
|
||||
metadata.isPNG = function (data: Uint8Array) {
|
||||
return metadata.compare(data, metadata.PNG_SIG);
|
||||
};
|
||||
|
||||
metadata.splitChunk = (arraybuffer: ArrayBuffer): PNGChunk[] => {
|
||||
let data = new Uint8Array(arraybuffer);
|
||||
|
||||
const sig = data.slice(0, 8);
|
||||
if (!metadata.isPNG(sig)) return [];
|
||||
|
||||
data = data.slice(8); // chomp sig
|
||||
|
||||
var chunklist = [];
|
||||
// read chunk list
|
||||
while (data.length > 0) {
|
||||
// read chunk size
|
||||
var size = stoi(data.slice(0, 4));
|
||||
if (size < 0) {
|
||||
// If the size is negative, the data is likely corrupt, but we'll let
|
||||
// the caller decide if any of the returned chunks are usable.
|
||||
// We'll move forward in the file with the minimum chunk length (12 bytes).
|
||||
size = 0;
|
||||
}
|
||||
let buf = data.slice(0, size + 12);
|
||||
data = data.slice(size + 12); // delete this chunk
|
||||
// read chunk data
|
||||
let chunk: PNGChunk = {
|
||||
size: size,
|
||||
type: metadata.decoder.decode(buf.slice(4, 8)),
|
||||
data: buf.slice(8, 8 + size),
|
||||
crc: stoi(buf.slice(8 + size, 8 + size + 4))
|
||||
};
|
||||
chunklist.push(chunk);
|
||||
}
|
||||
return chunklist;
|
||||
};
|
||||
|
||||
metadata.joinChunk = function (chunklist: PNGChunk[]) {
|
||||
let buffers = new Array();
|
||||
buffers.push(metadata.PNG_SIG);
|
||||
chunklist.forEach((chunk) => {
|
||||
buffers.push(new Uint8Array(itos(chunk.size, 4)));
|
||||
buffers.push(new Uint8Array([chunk.type.charCodeAt(0), chunk.type.charCodeAt(1), chunk.type.charCodeAt(2), chunk.type.charCodeAt(3)]));
|
||||
buffers.push(chunk.data);
|
||||
buffers.push(new Uint8Array(itos(chunk.crc, 4)));
|
||||
});
|
||||
|
||||
const cb = buffers.reduce((cb, buffer) => cb + buffer.byteLength, 0);
|
||||
const res = new Uint8Array(cb);
|
||||
let offset = 0;
|
||||
buffers.forEach((b) => {
|
||||
res.set(b, offset);
|
||||
offset += b.byteLength;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
metadata.createChunk = (type: string, data: string): PNGChunk => {
|
||||
return {
|
||||
size: data.length,
|
||||
type:type,
|
||||
data: metadata.encoder.encode(data),
|
||||
crc: crc32(type + data)
|
||||
};
|
||||
};
|
||||
|
||||
metadata.itos = itos;
|
||||
function itos(v: number, size: number) {
|
||||
let a = [];
|
||||
var t = size - 1;
|
||||
while (t >= 0) {
|
||||
var c = v & 0xff;
|
||||
a[t--] = c;
|
||||
v = v >> 8;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
metadata.stoi = stoi;
|
||||
function stoi(data: Uint8Array) {
|
||||
var v = 0;
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var c = data[i];
|
||||
v = (v << 8) | (c & 0xff);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function crc32(str: string) {
|
||||
const hexTable = [
|
||||
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832,
|
||||
0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2,
|
||||
0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, 0x646ba8c0, 0xfd62f97a,
|
||||
0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
|
||||
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3,
|
||||
0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423,
|
||||
0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab,
|
||||
0xb6662d3d, 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
|
||||
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, 0x6b6b51f4,
|
||||
0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950,
|
||||
0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074,
|
||||
0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
|
||||
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525,
|
||||
0x206f85b3, 0xb966d409, 0xce61e49f, 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
|
||||
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615,
|
||||
0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
|
||||
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, 0xfed41b76,
|
||||
0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e,
|
||||
0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c, 0x36034af6,
|
||||
0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
|
||||
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7,
|
||||
0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f,
|
||||
0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7,
|
||||
0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
|
||||
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278,
|
||||
0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc,
|
||||
0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, 0xbdbdf21c, 0xcabac28a, 0x53b39330,
|
||||
0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
|
||||
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d,
|
||||
];
|
||||
var crc = 0 ^ -1;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
crc = (crc >>> 8) ^ hexTable[(crc ^ str.charCodeAt(i)) & 0xff];
|
||||
}
|
||||
return (crc ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
export default metadata;
|
||||
|
|
@ -13,6 +13,8 @@ import duration from "dayjs/plugin/duration";
|
|||
import i18n from "./lang";
|
||||
import { toRaw, isRef, isReactive, isProxy } from "vue";
|
||||
import { UploadPromise } from "../models/attachment";
|
||||
import png from "@/plugins/png.ts";
|
||||
import { mediaMetadataToMediaInterventionFlags } from "../models/proof";
|
||||
|
||||
export const STATE_EVENT_ROOM_DELETION_NOTICE = "im.keanu.room_deletion_notice";
|
||||
export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted";
|
||||
|
|
@ -23,11 +25,30 @@ export const ROOM_TYPE_FILE_MODE = "im.keanu.room_type_file";
|
|||
export const ROOM_TYPE_CHANNEL = "im.keanu.room_type_channel";
|
||||
|
||||
export const STATE_EVENT_ROOM_TYPE = "im.keanu.room_type";
|
||||
export const CLIENT_EVENT_PROOF_HINT = "im.keanu.proof_hint";
|
||||
export const CLIENT_EVENT_MEDIA_INTERVENTION_FLAGS = "im.keanu.proof_hint";
|
||||
|
||||
export const THUMBNAIL_MAX_WIDTH = 640;
|
||||
export const THUMBNAIL_MAX_HEIGHT = 640;
|
||||
|
||||
export const supportedImageTypes = [
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
"image/jpg",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
export const supportedAvatarImageTypes = [
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
"image/jpg",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
// Install extended localized format
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(duration);
|
||||
|
|
@ -396,7 +417,7 @@ class Util {
|
|||
return [encryptedBytes, encryptedFile];
|
||||
}
|
||||
|
||||
sendFile(matrixClient, roomId, file, onUploadProgress, threadRoot, dimensions, thumbnail, proofHintFlags) {
|
||||
sendFile(matrixClient, roomId, file, onUploadProgress, threadRoot, dimensions, thumbnail, mediaMetadata) {
|
||||
const uploadPromise = new UploadPromise(undefined);
|
||||
uploadPromise.wrappedPromise = new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
|
|
@ -435,7 +456,7 @@ class Util {
|
|||
};
|
||||
}
|
||||
|
||||
if (file.type.startsWith("image/")) {
|
||||
if (this.isSupportedImageType(file.type)) {
|
||||
msgtype = "m.image";
|
||||
|
||||
// Generate thumbnail?
|
||||
|
|
@ -477,9 +498,10 @@ class Util {
|
|||
msgtype: msgtype,
|
||||
};
|
||||
|
||||
// if (proofHintFlags) {
|
||||
// messageContent[CLIENT_EVENT_PROOF_HINT] = JSON.stringify(proofHintFlags);
|
||||
// }
|
||||
if (mediaMetadata) {
|
||||
const interventionFlags = mediaMetadataToMediaInterventionFlags(mediaMetadata);
|
||||
messageContent[CLIENT_EVENT_MEDIA_INTERVENTION_FLAGS] = JSON.stringify(interventionFlags);
|
||||
}
|
||||
|
||||
// If thread root (an eventId) is set, add that here
|
||||
if (threadRoot) {
|
||||
|
|
@ -831,7 +853,24 @@ class Util {
|
|||
return images;
|
||||
}
|
||||
|
||||
setAvatar(matrix, file, onUploadProgress) {
|
||||
/**
|
||||
* For one of the predefined avatars, make it system unique by adding a random PNG comment header.
|
||||
*/
|
||||
makeUniqueAvatar(image) {
|
||||
var list = png.splitChunk(image);
|
||||
|
||||
// append
|
||||
var iend = list.pop(); // remove IEND
|
||||
var newchunk = png.createChunk("tEXt","Comment\0Keanu avatar - " + this.randomPass());
|
||||
list.push(newchunk);
|
||||
list.push(iend);
|
||||
|
||||
// join
|
||||
var newpng = png.joinChunk(list);
|
||||
return newpng;
|
||||
}
|
||||
|
||||
setAvatar(matrix, file, onUploadProgress, makeUnique) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.get(file, { responseType: "arraybuffer" })
|
||||
|
|
@ -842,9 +881,16 @@ class Util {
|
|||
progressHandler: onUploadProgress,
|
||||
onlyContentUri: false,
|
||||
};
|
||||
let data = response.data;
|
||||
|
||||
// If making a system unique avatar, we add a random PNG header in here
|
||||
if (makeUnique) {
|
||||
data = this.makeUniqueAvatar(data);
|
||||
}
|
||||
|
||||
var avatarUri;
|
||||
matrix.matrixClient
|
||||
.uploadContent(response.data, opts)
|
||||
.uploadContent(data, opts)
|
||||
.then((response) => {
|
||||
avatarUri = response.content_uri;
|
||||
return matrix.matrixClient.setAvatarUrl(avatarUri);
|
||||
|
|
@ -913,7 +959,7 @@ class Util {
|
|||
var reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const file = event.target.files[0];
|
||||
if (file.type.startsWith("image/")) {
|
||||
if (this.isSupportedImageType(file.type)) {
|
||||
try {
|
||||
var image = e.target.result;
|
||||
|
||||
|
|
@ -1015,6 +1061,24 @@ class Util {
|
|||
return then.format("lll");
|
||||
}
|
||||
|
||||
parseExifDate(exifDateString) {
|
||||
// Use a regular expression to match and capture the date/time components.
|
||||
const regex = /(\d{4}):(\d{2}):(\d{2})\s(\d{2}):(\d{2}):(\d{2})/;
|
||||
const match = exifDateString.match(regex);
|
||||
|
||||
if (match) {
|
||||
// Extract components and convert to numbers. Note that months are 0-indexed.
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10) - 1;
|
||||
const day = parseInt(match[3], 10);
|
||||
const hour = parseInt(match[4], 10);
|
||||
const minute = parseInt(match[5], 10);
|
||||
const second = parseInt(match[6], 10);
|
||||
return new Date(year, month, day, hour, minute, second);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
browserCanRecordAudio() {
|
||||
return _browserCanRecordAudio;
|
||||
}
|
||||
|
|
@ -1191,6 +1255,10 @@ class Util {
|
|||
return false;
|
||||
}
|
||||
|
||||
isSupportedImageType(mime) {
|
||||
return supportedImageTypes.some(prefix => mime.startsWith(prefix));
|
||||
}
|
||||
|
||||
isMobileOrTabletBrowser() {
|
||||
// Regular expression to match common mobile and tablet browser user agent strings
|
||||
const mobileTabletPattern = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Tablet|Mobile|CriOS/i;
|
||||
|
|
|
|||
|
|
@ -876,11 +876,7 @@ export default {
|
|||
const withRetry = (codeBlock) => {
|
||||
return codeBlock().catch((error) => {
|
||||
if (error && error.errcode == "M_LIMIT_EXCEEDED") {
|
||||
var retry = 1000;
|
||||
if (error.data) {
|
||||
const retryIn = error.data.retry_after_ms;
|
||||
retry = Math.max(retry, retryIn ? retryIn : 0);
|
||||
}
|
||||
const retry = error.getRetryAfterMs() ?? 1000;
|
||||
console.log("Rate limited, retry in", retry);
|
||||
return sleep(retry).then(() => withRetry(codeBlock));
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue