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",
|
"name": "keanuapp-weblite",
|
||||||
"version": "0.1.72",
|
"version": "0.1.88",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"vue-3-sanitize": "^0.1.4",
|
"vue-3-sanitize": "^0.1.4",
|
||||||
"vue-clipboard2": "^0.3.3",
|
"vue-clipboard2": "^0.3.3",
|
||||||
"vue-i18n": "^11.1.3",
|
"vue-i18n": "^11.1.3",
|
||||||
|
"vue-pdf-embed": "^2.1.3",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue-swipeable-bottom-sheet": "^0.0.5",
|
"vue-swipeable-bottom-sheet": "^0.0.5",
|
||||||
"vue3-emoji-picker": "^1.1.8",
|
"vue3-emoji-picker": "^1.1.8",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "keanuapp-weblite",
|
"name": "keanuapp-weblite",
|
||||||
"version": "0.1.71",
|
"version": "0.1.87",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"vue-3-sanitize": "^0.1.4",
|
"vue-3-sanitize": "^0.1.4",
|
||||||
"vue-clipboard2": "^0.3.3",
|
"vue-clipboard2": "^0.3.3",
|
||||||
"vue-i18n": "^11.1.3",
|
"vue-i18n": "^11.1.3",
|
||||||
|
"vue-pdf-embed": "^2.1.3",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vue-swipeable-bottom-sheet": "^0.0.5",
|
"vue-swipeable-bottom-sheet": "^0.0.5",
|
||||||
"vue3-emoji-picker": "^1.1.8",
|
"vue3-emoji-picker": "^1.1.8",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ $hiliteColor: #4642f1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
&.file-drop {
|
||||||
|
flex: 0 0 100px;
|
||||||
|
background-color: transparent;
|
||||||
|
flex-direction: column;
|
||||||
|
.file-drop-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-btn {
|
.v-btn {
|
||||||
|
|
@ -58,6 +66,10 @@ $hiliteColor: #4642f1;
|
||||||
height: $large-button-height;
|
height: $large-button-height;
|
||||||
border-radius: $large-button-height * 0.5;
|
border-radius: $large-button-height * 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.text {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button {
|
.back-button {
|
||||||
|
|
@ -97,25 +109,24 @@ $hiliteColor: #4642f1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
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 {
|
.file-drop-choose-files {
|
||||||
position: absolute;
|
background-color: $backgroundSection;
|
||||||
right: 12px;
|
border-radius: 19px;
|
||||||
bottom: 12px;
|
padding: 16px 18px;
|
||||||
|
flex: 0 0 40%;
|
||||||
|
margin: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: end;
|
.file-format-info {
|
||||||
justify-content: end;
|
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 {
|
.attachment-info {
|
||||||
text-align: start;
|
text-align: start;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.attachment-info__content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.attachment-info__quality {
|
.attachment-info__quality {
|
||||||
.attachment-info__quality__title {
|
.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 {
|
.detail-row {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
font-family: "Inter", sans-serif;
|
font-family: "Inter", sans-serif;
|
||||||
|
|
@ -633,11 +636,13 @@ $hiliteColor: #4642f1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
.v-icon {
|
.v-icon {
|
||||||
|
color: #dad9fc;
|
||||||
padding: 9.33px;
|
padding: 9.33px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row__text {
|
.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">
|
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<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"
|
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
|
<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"
|
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
|
<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"
|
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
|
<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"
|
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>
|
</svg></template>
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
<style>
|
<style scoped>
|
||||||
path {
|
path {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<g clip-path="url(#clip1_770_11697)">
|
<g clip-path="url(#clip1_770_11697)">
|
||||||
<path
|
<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"
|
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-width="1.5"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<g clip-path="url(#clip1_770_11705)">
|
<g clip-path="url(#clip1_770_11705)">
|
||||||
<path
|
<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"
|
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-width="1.5"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="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} من الأحداث",
|
"processed_n_of_total_events": "تمت معالجة الوسائط لـ {count} من أصل {total} من الأحداث",
|
||||||
"export_filename": "تم تصدير المحادثة {date}"
|
"export_filename": "تم تصدير المحادثة {date}"
|
||||||
},
|
},
|
||||||
"language_display_name": "الإنجليزية",
|
|
||||||
"global": {
|
"global": {
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
"password_didnot_match": "كلمة السر غير متطابقة",
|
"password_didnot_match": "كلمة السر غير متطابقة",
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@
|
||||||
"user_make_moderator": "মডারেটর বানান",
|
"user_make_moderator": "মডারেটর বানান",
|
||||||
"user_revoke_moderator": "মডারেটর বাদ দিন"
|
"user_revoke_moderator": "মডারেটর বাদ দিন"
|
||||||
},
|
},
|
||||||
"language_display_name": "ইংরেজি",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Convene",
|
"name": "Convene",
|
||||||
"tag_line": "শুধুমাত্র সংযোগ করুন"
|
"tag_line": "শুধুমাত্র সংযোগ করুন"
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,8 @@
|
||||||
"sent_media": "སྨྱན་སྦྱོར་གྲངས་{count}་བཏང་ཟིན།",
|
"sent_media": "སྨྱན་སྦྱོར་གྲངས་{count}་བཏང་ཟིན།",
|
||||||
"room_upgraded": "ཁ་བརྡ་ཁང་འདི་རིམ་སྤར་བྱས་སོང་། {link}འབྲེལ་ཐག་འདི་བརྒྱུད་ནས་བསྐྱར་དུ་ཞུགས།",
|
"room_upgraded": "ཁ་བརྡ་ཁང་འདི་རིམ་སྤར་བྱས་སོང་། {link}འབྲེལ་ཐག་འདི་བརྒྱུད་ནས་བསྐྱར་དུ་ཞུགས།",
|
||||||
"room_upgraded_link": "འདི་ནས",
|
"room_upgraded_link": "འདི་ནས",
|
||||||
"room_upgraded_view_old": "ཁ་བརྡ་ཁང་འདི་རིམ་སྤར་བྱས་སོང་། {link}འདིའི་ཐོག་ཏུ་མནན་ཏེ་ཆ་འཕྲིན་རྙིང་པར་གཟིགས།"
|
"room_upgraded_view_old": "ཁ་བརྡ་ཁང་འདི་རིམ་སྤར་བྱས་སོང་། {link}འདིའི་ཐོག་ཏུ་མནན་ཏེ་ཆ་འཕྲིན་རྙིང་པར་གཟིགས།",
|
||||||
|
"download_media": "སྨྱན་སྦྱོར་ཕབ་ལེན།"
|
||||||
},
|
},
|
||||||
"power_level": {
|
"power_level": {
|
||||||
"moderator": "མདོ་འཛིན་པ།",
|
"moderator": "མདོ་འཛིན་པ།",
|
||||||
|
|
@ -359,7 +360,6 @@
|
||||||
"close_tab": "བཤེར་ཆས་ཁ་རྒྱོབས།",
|
"close_tab": "བཤེར་ཆས་ཁ་རྒྱོབས།",
|
||||||
"room_deleted": "ཁ་བརྡ་ཁང་མེད་པར་བཟོས་སོང་།"
|
"room_deleted": "ཁ་བརྡ་ཁང་མེད་པར་བཟོས་སོང་།"
|
||||||
},
|
},
|
||||||
"language_display_name": "བོད་ཡིག",
|
|
||||||
"global": {
|
"global": {
|
||||||
"save": "ཉར་ཚགས།",
|
"save": "ཉར་ཚགས།",
|
||||||
"password_didnot_match": "གསང་ཚིག་མཐུན་གྱི་མི་འདུག",
|
"password_didnot_match": "གསང་ཚིག་མཐུན་གྱི་མི་འདུག",
|
||||||
|
|
@ -377,10 +377,15 @@
|
||||||
"notify": "བརྡ་ཁྱབ་གཏོང་བ།",
|
"notify": "བརྡ་ཁྱབ་གཏོང་བ།",
|
||||||
"close": "སྒོ་རྒྱོབ།",
|
"close": "སྒོ་རྒྱོབ།",
|
||||||
"different_browser_title": "བཤེར་ཆས་གཞན་ཞིག་སྤྱོད་རོགས།",
|
"different_browser_title": "བཤེར་ཆས་གཞན་ཞིག་སྤྱོད་རོགས།",
|
||||||
"different_browser_content": "ཁྱད་ཆོས་ཁག་ཅིག་ནུས་མེད་ཆགས་སྲིད་པས། འབྲེལ་ཐག་པར་བཤུས་བྱས་ཏེ། བཤེར་ཆས་གཞན་ཞིག་གི་ནང་དུ་ཁ་འབྱེད་རོགས།"
|
"different_browser_content": "ཁྱད་ཆོས་ཁག་ཅིག་ནུས་མེད་ཆགས་སྲིད་པས། འབྲེལ་ཐག་པར་བཤུས་བྱས་ཏེ། བཤེར་ཆས་གཞན་ཞིག་གི་ནང་དུ་ཁ་འབྱེད་རོགས།",
|
||||||
|
"retry": "བསྐྱར་དུ་ཚོད་ལྟ།"
|
||||||
},
|
},
|
||||||
"logout": {
|
"logout": {
|
||||||
"confirm_text": "ཁྱེད་རང་ཁ་བརྡ་ཁང་ནས་ཕྱི་རུ་ཐོན་རྒྱུ་ཡིན་ནམ།"
|
"confirm_text": "ཁྱེད་རང་ཁ་བརྡ་ཁང་ནས་ཕྱི་རུ་ཐོན་རྒྱུ་ཡིན་ནམ།",
|
||||||
|
"copy_credentials": "རྒྱུན་སྤྱོད་མིང་དང་གསང་གྲངས་འདྲ་བཤུས་བྱོས།",
|
||||||
|
"copied_credentials": "རྒྱུན་སྤྱོད་མིང་དང་གསང་གྲངས་འདྲ་བཤུས་བྱས་ཚར།",
|
||||||
|
"copied_credentials_value": "རྒྱུན་སྤྱོད་མིང་། : {userId} \n\nགསང་གྲངས། : {password}",
|
||||||
|
"copy_credentials_desc": "ཁ་པར་དང་བཤེར་ཆས་གསར་པ་ཞིག་ནས་ཁ་བརྡའི་ནང་འཛུལ་དགོས་ན། ཁྱེད་ཀྱི་རྒྱུན་སྤྱོད་མིང་དང་གསང་གྲངས་དགོས་པས། དེ་དག་གནས་བརྟན་པོ་ཞིག་ལ་ཉར་རྒྱུ་རེ་སྐུལ་ཞུ་གི་ཡོད།"
|
||||||
},
|
},
|
||||||
"poll_create": {
|
"poll_create": {
|
||||||
"failed": "བསམ་ཚུལ་བསྡུ་ལེན་གྱི་འདེམས་ཤོག་བཟོ་ཐུབ་མ་སོང་། རྗེས་སུ་བསྐྱར་དུ་ཚོད་ལྟ་བྱོས།",
|
"failed": "བསམ་ཚུལ་བསྡུ་ལེན་གྱི་འདེམས་ཤོག་བཟོ་ཐུབ་མ་སོང་། རྗེས་སུ་བསྐྱར་དུ་ཚོད་ལྟ་བྱོས།",
|
||||||
|
|
@ -434,13 +439,17 @@
|
||||||
"metadata_info_compressed": "པར་རིས་གནོན་བཙིར་བྱས་ན། དེའི་རྒྱུའི་གཞི་གྲངས་རང་བཞིན་གྱིས་འདོར་ངེས།",
|
"metadata_info_compressed": "པར་རིས་གནོན་བཙིར་བྱས་ན། དེའི་རྒྱུའི་གཞི་གྲངས་རང་བཞིན་གྱིས་འདོར་ངེས།",
|
||||||
"metadata_info_original": "དང་ཐོག་གི་པར་གཞི་བརྒྱུད་སྐུར་བྱས་ན། དེའི་རྒྱུའི་གཞི་གྲངས་རང་བཞིན་གྱིས་ནང་དུ་ཚུད་འགྲོ།",
|
"metadata_info_original": "དང་ཐོག་གི་པར་གཞི་བརྒྱུད་སྐུར་བྱས་ན། དེའི་རྒྱུའི་གཞི་གྲངས་རང་བཞིན་གྱིས་ནང་དུ་ཚུད་འགྲོ།",
|
||||||
"exif_data": "Exif གཞི་གྲངས།",
|
"exif_data": "Exif གཞི་གྲངས།",
|
||||||
"content_credentials": "ནང་དོན་བདེན་དཔང་།",
|
|
||||||
"content_credentials_info": "སྨྱན་སྦྱོར་འདི་བདེན་དཔང་བྱེད་པར་ཁུངས་དང་ལོ་རྒྱུས་ཀྱི་ཆ་འཕྲིན་ཡོད།",
|
|
||||||
"learn_more": "ཞིབ་ཕྲ་སློབ་སྦྱོང་བྱོས།",
|
"learn_more": "ཞིབ་ཕྲ་སློབ་སྦྱོང་བྱོས།",
|
||||||
"ai_used": "མིས་བཟོས་རིག་ནུས་ཀྱིས་པར་རིས་བཅོས་སྒྱུར་བྱས་འདུག",
|
"ai_used": "མིས་བཟོས་རིག་ནུས་ཀྱིས་པར་རིས་བཅོས་སྒྱུར་བྱས་འདུག",
|
||||||
"screenshot_taken_on": "{date}ཉིན་འཆར་ངོས་པར་བླངས་འདུག",
|
"screenshot_taken_on": "{date}ཉིན་འཆར་ངོས་པར་བླངས་འདུག",
|
||||||
"captured_with_camera": "པར་ཆས་ཤིག་གིས་པར་བླངས་འདུག",
|
"captured_with_camera": "པར་ཆས་ངོ་མ་ཞིག་གིས་པར་བླངས་འདུག ",
|
||||||
"old_photo": "ཟླ་བ་གསུམ་ལས་རྙིང་པའི་པར།"
|
"old_photo": "ཟླ་བ་གསུམ་ལས་རྙིང་པའི་པར།. ",
|
||||||
|
"cc_source": "ཁུངས།",
|
||||||
|
"cc_capture_timestamp": "ལོ་ཟླ་ཚེས་གྲངས་འཛིན་པ།",
|
||||||
|
"cc_location": "ས་གནས།",
|
||||||
|
"screenshot": "བརྙན་ཤེལ་པར་རིས། ",
|
||||||
|
"captured_screenshot": "བརྙན་ཤེལ་པར་རིས། ",
|
||||||
|
"captured_screenshot_ago": "{ago} གོང་ལ་བརྙན་ཤེལ་པར་རིས་དེ་བླངས་འདུག "
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
|
|
@ -506,5 +515,24 @@
|
||||||
"filedrop_name": "ཁྱེད་ཀྱི་ཡིག་ཆ་འཇོག་སྒྲོམ་ལ་མིང་ཞིག་ཐོགས།",
|
"filedrop_name": "ཁྱེད་ཀྱི་ཡིག་ཆ་འཇོག་སྒྲོམ་ལ་མིང་ཞིག་ཐོགས།",
|
||||||
"status_creating": "ཡིག་ཆ་འཇོག་སྒྲོམ་བཟོ་བཞིན་པ།",
|
"status_creating": "ཡིག་ཆ་འཇོག་སྒྲོམ་བཟོ་བཞིན་པ།",
|
||||||
"error_filedrop": "ཡིག་ཆ་འཇོག་སྒྲོམ་བཟོ་ཐུབ་མ་སོང་།"
|
"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": {
|
"menu": {
|
||||||
"start_private_chat": "Private Diskussion mit diesem Benutzer",
|
"start_private_chat": "Private Diskussion mit diesem Benutzer",
|
||||||
"reply": "Antworten",
|
"reply": "Antworten",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"language_display_name": "English",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Convene",
|
"name": "Convene",
|
||||||
"tag_line": "Simply connect"
|
"tag_line": "Simply connect"
|
||||||
|
|
@ -117,6 +116,7 @@
|
||||||
"files": "Files",
|
"files": "Files",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"send_attachements_dialog_title": "Do you want to send following attachments?",
|
"send_attachements_dialog_title": "Do you want to send following attachments?",
|
||||||
|
"download_media": "Download media",
|
||||||
"download_all": "Download all",
|
"download_all": "Download all",
|
||||||
"failed_to_render": "Failed to render event",
|
"failed_to_render": "Failed to render event",
|
||||||
"room_upgraded": "This room has been upgraded, go {link} to rejoin the discussion",
|
"room_upgraded": "This room has been upgraded, go {link} to rejoin the discussion",
|
||||||
|
|
@ -319,7 +319,15 @@
|
||||||
"leave": "Leave"
|
"leave": "Leave"
|
||||||
},
|
},
|
||||||
"logout": {
|
"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": {
|
"purge_room": {
|
||||||
"title": "Delete room?",
|
"title": "Delete room?",
|
||||||
|
|
@ -392,6 +400,7 @@
|
||||||
"message_history_warning": "warning: Full message history will be visible to new participants",
|
"message_history_warning": "warning: Full message history will be visible to new participants",
|
||||||
"report": "Report",
|
"report": "Report",
|
||||||
"report_info": "Report this room to service administrators for illegal, abusive or otherwise harmful content",
|
"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",
|
"report_reason": "Reason",
|
||||||
"confirm_make_admin": "Do you want to make this user an administrator?",
|
"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?",
|
"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_with_camera": "Captured with a real camera. ",
|
||||||
"captured_screenshot": "Screenshot. ",
|
"captured_screenshot": "Screenshot. ",
|
||||||
"captured_screenshot_ago": "Screenshot captured {ago} ago. ",
|
"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. ",
|
"old_photo": "Photo older than 3 months. ",
|
||||||
"cc_source": "Source",
|
"cc_source": "Source",
|
||||||
"cc_capture_timestamp": "Capture Timestamp",
|
"cc_capture_timestamp": "Capture Timestamp",
|
||||||
"cc_location": "Location"
|
"cc_location": "Location"
|
||||||
},
|
},
|
||||||
"cc": {
|
"cc": {
|
||||||
"content_credentials": "Content Credentials",
|
"details": "Details",
|
||||||
"content_credentials_info": "Source or history information is available for this media to be verified.",
|
"cc_info": "More information is available. Upload the image to {link}",
|
||||||
"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."
|
"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": {
|
"project": {
|
||||||
"name": "Convene",
|
"name": "Convene",
|
||||||
"tag_line": "Simplemente conectar"
|
"tag_line": "Simplemente conectar"
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@
|
||||||
"user_make_moderator": "Hacer moderador(a)",
|
"user_make_moderator": "Hacer moderador(a)",
|
||||||
"user_revoke_moderator": "Revocar moderador(a)"
|
"user_revoke_moderator": "Revocar moderador(a)"
|
||||||
},
|
},
|
||||||
"language_display_name": "Inglés",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Convene",
|
"name": "Convene",
|
||||||
"tag_line": "Simplemente conecta"
|
"tag_line": "Simplemente conecta"
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,6 @@
|
||||||
"places": "Lugares"
|
"places": "Lugares"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"language_display_name": "Inglés",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Convene",
|
"name": "Convene",
|
||||||
"tag_line": "Simplemente conéctate"
|
"tag_line": "Simplemente conéctate"
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,15 @@
|
||||||
"send_attachements_dialog_title": "آیا میخواهید پیوستهای زیر را ارسال کنید؟",
|
"send_attachements_dialog_title": "آیا میخواهید پیوستهای زیر را ارسال کنید؟",
|
||||||
"download_all": "دانلود همه",
|
"download_all": "دانلود همه",
|
||||||
"failed_to_render": "تحویل رویداد با شکست مواجه شد",
|
"failed_to_render": "تحویل رویداد با شکست مواجه شد",
|
||||||
"room_upgraded_link": "اینجا"
|
"room_upgraded_link": "اینجا",
|
||||||
|
"download_media": "بارگیری رسانه"
|
||||||
},
|
},
|
||||||
"new_room": {
|
"new_room": {
|
||||||
"next": "بعدی",
|
"next": "بعدی",
|
||||||
"new_room": "اتاق جدید",
|
"new_room": "اتاق جدید",
|
||||||
"create": "ایجاد کنید",
|
"create": "ایجاد کنید",
|
||||||
"name_room": "نام اتاق",
|
"name_room": "نام اتاق",
|
||||||
"room_topic": "در صورت تمایل، توضیحی اضافه کنید.",
|
"room_topic": "افزودن شرح دلخواه",
|
||||||
"join_permissions": "مجوزهای پیوستن",
|
"join_permissions": "مجوزهای پیوستن",
|
||||||
"set_join_permissions": "مجوزهای پیوستن را تنظیم کنید",
|
"set_join_permissions": "مجوزهای پیوستن را تنظیم کنید",
|
||||||
"join_permissions_info": "این مجوزها تعیین میکنند که چگونه افراد میتوانند به اتاق بپیوندند و دیگران چقدر راحت میتوانند دعوت شوند. این تنظیمات در هر زمان قابل تغییر هستند.",
|
"join_permissions_info": "این مجوزها تعیین میکنند که چگونه افراد میتوانند به اتاق بپیوندند و دیگران چقدر راحت میتوانند دعوت شوند. این تنظیمات در هر زمان قابل تغییر هستند.",
|
||||||
|
|
@ -158,9 +159,10 @@
|
||||||
"quality": "کیفیت",
|
"quality": "کیفیت",
|
||||||
"original": "اصلی",
|
"original": "اصلی",
|
||||||
"learn_more": "بیشتر بدانید",
|
"learn_more": "بیشتر بدانید",
|
||||||
"compressed": "فشرده"
|
"compressed": "فشرده",
|
||||||
|
"cc_source": "منبع",
|
||||||
|
"cc_location": "مکان"
|
||||||
},
|
},
|
||||||
"language_display_name": "انگلیسی",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "تجمع",
|
"name": "تجمع",
|
||||||
"tag_line": "به سادگی متصل شوید"
|
"tag_line": "به سادگی متصل شوید"
|
||||||
|
|
@ -182,7 +184,8 @@
|
||||||
"close": "ببندید",
|
"close": "ببندید",
|
||||||
"notify": "اطلاع بدهید",
|
"notify": "اطلاع بدهید",
|
||||||
"different_browser_title": "مرورگر متفاوتی را امتحان کنید",
|
"different_browser_title": "مرورگر متفاوتی را امتحان کنید",
|
||||||
"different_browser_content": "برخی ویژگیها ممکن است از کار بیفتند. لینک را کپی کرده و در مرورگر دیگری باز کنید."
|
"different_browser_content": "برخی ویژگیها ممکن است از کار بیفتند. لینک را کپی کرده و در مرورگر دیگری باز کنید.",
|
||||||
|
"retry": "تلاش دوباره"
|
||||||
},
|
},
|
||||||
"room": {
|
"room": {
|
||||||
"unseen_messages": "شما هیچ پیام نادیدهای ندارید | شما 1 پیام نادیده دارید | شما {count} پیام نادیده دارید",
|
"unseen_messages": "شما هیچ پیام نادیدهای ندارید | شما 1 پیام نادیده دارید | شما {count} پیام نادیده دارید",
|
||||||
|
|
@ -333,7 +336,7 @@
|
||||||
"leave_room": "ترک کنید",
|
"leave_room": "ترک کنید",
|
||||||
"version_info": "پشتیبانی شده توسط پروژه Guardian. نسخه: {version}",
|
"version_info": "پشتیبانی شده توسط پروژه Guardian. نسخه: {version}",
|
||||||
"scan_code": "برای پیوستن به اتاق اسکن کنید",
|
"scan_code": "برای پیوستن به اتاق اسکن کنید",
|
||||||
"export_room": "از چت، خروجی بگیرید",
|
"export_room": "ذخیرهٔ گپ",
|
||||||
"user_admin": "ادمین",
|
"user_admin": "ادمین",
|
||||||
"user_moderator": "ناظم",
|
"user_moderator": "ناظم",
|
||||||
"moderation": "مدیریت",
|
"moderation": "مدیریت",
|
||||||
|
|
@ -344,11 +347,11 @@
|
||||||
"voice_mode_info": "رابطِ چت را به حالت «گوش دادن و ضبط» تغییر میدهد.",
|
"voice_mode_info": "رابطِ چت را به حالت «گوش دادن و ضبط» تغییر میدهد.",
|
||||||
"file_mode": "حالت فایل",
|
"file_mode": "حالت فایل",
|
||||||
"file_mode_info": "رابطِ چت را به حالت «کشیدن و رها کردن فایل» تغییر میدهد.",
|
"file_mode_info": "رابطِ چت را به حالت «کشیدن و رها کردن فایل» تغییر میدهد.",
|
||||||
"download_chat": "دانلود چت",
|
"download_chat": "ذخیرهٔ گپ",
|
||||||
"read_only_room": "فقط خواندن",
|
"read_only_room": "فقط خواندن",
|
||||||
"read_only_room_info": "فقط ادمینها و ناظمین مجاز به ارسال پیام به اتاق هستند.",
|
"read_only_room_info": "فقط ادمینها و ناظمین مجاز به ارسال پیام به اتاق هستند.",
|
||||||
"message_retention": "تاریخچه پیام",
|
"message_retention": "محدودیت تاریخچه",
|
||||||
"message_retention_info": "پیامهای ارسال شده در این بازه زمانی برای هر کسی که لینک را داشته باشد قابل مشاهده است.",
|
"message_retention_info": "تنظیم محدودیتی برای نگه داشتن پیامها",
|
||||||
"message_retention_none": "خاموش",
|
"message_retention_none": "خاموش",
|
||||||
"message_retention_4_week": "4 هفته",
|
"message_retention_4_week": "4 هفته",
|
||||||
"message_retention_2_week": "2 هفته",
|
"message_retention_2_week": "2 هفته",
|
||||||
|
|
@ -364,8 +367,9 @@
|
||||||
"message_history": "تاریخچه پیام",
|
"message_history": "تاریخچه پیام",
|
||||||
"report_reason": "دلیل",
|
"report_reason": "دلیل",
|
||||||
"report": "گزارش",
|
"report": "گزارش",
|
||||||
"accept_knock": "قبول کنید",
|
"accept_knock": "پذیرش",
|
||||||
"reject_knock": "رد"
|
"reject_knock": "رد",
|
||||||
|
"user_creator": "سازنده"
|
||||||
},
|
},
|
||||||
"room_info_sheet": {
|
"room_info_sheet": {
|
||||||
"this_room": "این اتاق",
|
"this_room": "این اتاق",
|
||||||
|
|
@ -454,5 +458,13 @@
|
||||||
"field_required": "این فیلد ضروری است",
|
"field_required": "این فیلد ضروری است",
|
||||||
"room_type_channel_name": "کانال",
|
"room_type_channel_name": "کانال",
|
||||||
"topic_too_long": "بیشینه ۵۰ نویسه مجاز است"
|
"topic_too_long": "بیشینه ۵۰ نویسه مجاز است"
|
||||||
|
},
|
||||||
|
"cc": {
|
||||||
|
"details": "جزییات",
|
||||||
|
"screenshot": "نماگرفت.",
|
||||||
|
"screenshot_probably": "تصویر شبیه به نماگرفت است.",
|
||||||
|
"generated_with_ai": "ساخته شده با هوشی.",
|
||||||
|
"modified": "دستکاری شده.",
|
||||||
|
"older_than_n_months": "قدیمیتر از {n} ماه."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,6 @@
|
||||||
},
|
},
|
||||||
"search": "جستجو..."
|
"search": "جستجو..."
|
||||||
},
|
},
|
||||||
"language_display_name": "انگلیسی",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "تجمع",
|
"name": "تجمع",
|
||||||
"tag_line": "بطور ساده وصل شوید"
|
"tag_line": "بطور ساده وصل شوید"
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@
|
||||||
"video_file": "Videotiedosto",
|
"video_file": "Videotiedosto",
|
||||||
"download_name": "Lataus"
|
"download_name": "Lataus"
|
||||||
},
|
},
|
||||||
"language_display_name": "suomi",
|
|
||||||
"device_list": {
|
"device_list": {
|
||||||
"verified": "Vahvistettu",
|
"verified": "Vahvistettu",
|
||||||
"blocked": "Estetty",
|
"blocked": "Estetty",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
"user_make_admin": "Rendre administrateur",
|
"user_make_admin": "Rendre administrateur",
|
||||||
"direct_chat": "Discussion directe"
|
"direct_chat": "Discussion directe"
|
||||||
},
|
},
|
||||||
"language_display_name": "français",
|
|
||||||
"message": {
|
"message": {
|
||||||
"you": "Vous",
|
"you": "Vous",
|
||||||
"user_created_room": "{user} a créé le salon",
|
"user_created_room": "{user} a créé le salon",
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@
|
||||||
"download_all": "Íoslódáil go léir",
|
"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": "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_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": {
|
"room_welcome": {
|
||||||
"info_permissions": "Is féidir leat 'ceadanna páirti' a athrú ag am ar bith i socruithe an tseomra.",
|
"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_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 é.",
|
"metadata_info_original": "Cuirtear meiteashonraí an bhunchóip san áireamh go huathoibríoch nuair a roinntear é.",
|
||||||
"exif_data": "Sonraí Exif",
|
"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",
|
"ai_used": "Grianghraf modhnaithe le hintleacht shaorga",
|
||||||
"screenshot_taken_on": "Scáileán tógtha ar {date}",
|
"screenshot_taken_on": "Scáileán tógtha ar {date}",
|
||||||
"captured_with_camera": "Gabhadh le ceamara",
|
"captured_with_camera": "Gabhadh le ceamara fíor. ",
|
||||||
"old_photo": "Grianghraf níos sine ná 3 mhí"
|
"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": {
|
"project": {
|
||||||
"name": "Tionól",
|
"name": "Tionól",
|
||||||
|
|
@ -315,9 +320,9 @@
|
||||||
"close": "dún",
|
"close": "dún",
|
||||||
"notify": "Tabhair fógra",
|
"notify": "Tabhair fógra",
|
||||||
"different_browser_title": "Bain triail as brabhsálaithe",
|
"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": {
|
"menu": {
|
||||||
"start_private_chat": "Teachtaireacht Díreach leis an úsáideoir seo",
|
"start_private_chat": "Teachtaireacht Díreach leis an úsáideoir seo",
|
||||||
"direct_chat": "Comhrá díreach",
|
"direct_chat": "Comhrá díreach",
|
||||||
|
|
@ -474,7 +479,11 @@
|
||||||
"restricted": "srianta"
|
"restricted": "srianta"
|
||||||
},
|
},
|
||||||
"logout": {
|
"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": {
|
"fallbacks": {
|
||||||
"audio_file": "Comhad fuaime",
|
"audio_file": "Comhad fuaime",
|
||||||
|
|
@ -506,5 +515,24 @@
|
||||||
"error_filedrop": "Theip ar chruthú anuas comhaid",
|
"error_filedrop": "Theip ar chruthú anuas comhaid",
|
||||||
"status_creating": "Titim comhaid a chruthú",
|
"status_creating": "Titim comhaid a chruthú",
|
||||||
"filedrop_name": "Ainmnigh do chomhad anuas"
|
"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",
|
"video_file": "File video",
|
||||||
"original_text": "<testo originale>"
|
"original_text": "<testo originale>"
|
||||||
},
|
},
|
||||||
"language_display_name": "italiano",
|
|
||||||
"room_info_sheet": {
|
"room_info_sheet": {
|
||||||
"this_room": "Questa stanza",
|
"this_room": "Questa stanza",
|
||||||
"view_details": "Visualizza i dettagli"
|
"view_details": "Visualizza i dettagli"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"language_display_name": "អង់គ្លេស",
|
|
||||||
"global": {
|
"global": {
|
||||||
"save": "រក្សាទុក"
|
"save": "រក្សាទុក"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"language_display_name": "ئینگلیزی",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "کۆبوونەوە",
|
"name": "کۆبوونەوە",
|
||||||
"tag_line": "بە ئاسانی پەیوەندی بکە"
|
"tag_line": "بە ئاسانی پەیوەندی بکە"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
"different_browser_title": "ລອງບຣາວເຊີອື່ນ",
|
"different_browser_title": "ລອງບຣາວເຊີອື່ນ",
|
||||||
"different_browser_content": "ບາງຄຸນສົມບັດອາດຈະແຕກ. ສຳເນົາ ແລະ ເປີດລິ້ງໃນບຣາວເຊີອື່ນ"
|
"different_browser_content": "ບາງຄຸນສົມບັດອາດຈະແຕກ. ສຳເນົາ ແລະ ເປີດລິ້ງໃນບຣາວເຊີອື່ນ"
|
||||||
},
|
},
|
||||||
"language_display_name": "ພາສາອັງກິດ",
|
|
||||||
"menu": {
|
"menu": {
|
||||||
"edit": "ແກ້ໄຂ",
|
"edit": "ແກ້ໄຂ",
|
||||||
"delete": "ລຶບ",
|
"delete": "ລຶບ",
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,6 @@
|
||||||
"not_supported": "အသိပေးချက်ကို မိုဘိုင်းတွင် မရရှိနိုင်သေးပါ",
|
"not_supported": "အသိပေးချက်ကို မိုဘိုင်းတွင် မရရှိနိုင်သေးပါ",
|
||||||
"periodicSync_new_msg_reminder": "သင့်တွင် မက်ဆေ့ချ်အသစ်များ ရှိနိုင်ပါသည်"
|
"periodicSync_new_msg_reminder": "သင့်တွင် မက်ဆေ့ချ်အသစ်များ ရှိနိုင်ပါသည်"
|
||||||
},
|
},
|
||||||
"language_display_name": "အင်္ဂလိပ်",
|
|
||||||
"menu": {
|
"menu": {
|
||||||
"start_private_chat": "သုံးစွဲသူနှင့် တိုက်ရိုက်စာပို့ခြင်း",
|
"start_private_chat": "သုံးစွဲသူနှင့် တိုက်ရိုက်စာပို့ခြင်း",
|
||||||
"direct_chat": "တိုက်ရိုက် ခြက်",
|
"direct_chat": "တိုက်ရိုက် ခြက်",
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,6 @@
|
||||||
"done": "Ferdig",
|
"done": "Ferdig",
|
||||||
"user_kick_and_ban": "Kast ut"
|
"user_kick_and_ban": "Kast ut"
|
||||||
},
|
},
|
||||||
"language_display_name": "Norsk",
|
|
||||||
"global": {
|
"global": {
|
||||||
"save": "Lagre",
|
"save": "Lagre",
|
||||||
"time": {
|
"time": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"language_display_name": "انګریزې",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "را ټولیدل",
|
"name": "را ټولیدل",
|
||||||
"tag_line": "ډېر ساده وصل شئ"
|
"tag_line": "ډېر ساده وصل شئ"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@
|
||||||
"audio_file": "Arquivo de áudio",
|
"audio_file": "Arquivo de áudio",
|
||||||
"video_file": "Arquivo de vídeo"
|
"video_file": "Arquivo de vídeo"
|
||||||
},
|
},
|
||||||
"language_display_name": "Inglês",
|
|
||||||
"menu": {
|
"menu": {
|
||||||
"start_private_chat": "Bate-papo privado com este usuário",
|
"start_private_chat": "Bate-papo privado com este usuário",
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
|
|
@ -423,7 +422,6 @@
|
||||||
"send_more_files": "Enviar mais arquivos",
|
"send_more_files": "Enviar mais arquivos",
|
||||||
"quality": "Qualidade",
|
"quality": "Qualidade",
|
||||||
"original": "Original",
|
"original": "Original",
|
||||||
"content_credentials": "Credenciais do conteúdo",
|
|
||||||
"learn_more": "Saiba mais"
|
"learn_more": "Saiba mais"
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,6 @@
|
||||||
"files": "Ficheiros",
|
"files": "Ficheiros",
|
||||||
"quality": "Qualidade",
|
"quality": "Qualidade",
|
||||||
"original": "Original",
|
"original": "Original",
|
||||||
"content_credentials": "Credenciais do conteúdo",
|
|
||||||
"learn_more": "Saber mais"
|
"learn_more": "Saber mais"
|
||||||
},
|
},
|
||||||
"fallbacks": {
|
"fallbacks": {
|
||||||
|
|
@ -157,7 +156,6 @@
|
||||||
"video_file": "Ficheiro de vídeo",
|
"video_file": "Ficheiro de vídeo",
|
||||||
"original_text": "<texto original>"
|
"original_text": "<texto original>"
|
||||||
},
|
},
|
||||||
"language_display_name": "Inglês",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Convocar",
|
"name": "Convocar",
|
||||||
"tag_line": "Basta ligar"
|
"tag_line": "Basta ligar"
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,6 @@
|
||||||
"download_all": "Descărcați tot",
|
"download_all": "Descărcați tot",
|
||||||
"someone": "Cineva"
|
"someone": "Cineva"
|
||||||
},
|
},
|
||||||
"language_display_name": "Engleză",
|
|
||||||
"fallbacks": {
|
"fallbacks": {
|
||||||
"download_name": "Descărcați",
|
"download_name": "Descărcați",
|
||||||
"original_text": "<original text>",
|
"original_text": "<original text>",
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@
|
||||||
"join_public": "Любой, у кого есть ссылка",
|
"join_public": "Любой, у кого есть ссылка",
|
||||||
"copy_invite_link": "Скопировать ссылку на приглашение",
|
"copy_invite_link": "Скопировать ссылку на приглашение",
|
||||||
"scan_code": "Сканировать, чтобы присоединиться к комнате",
|
"scan_code": "Сканировать, чтобы присоединиться к комнате",
|
||||||
"message_retention": "История сообщений",
|
"message_retention": "Предел истории",
|
||||||
"message_retention_info": "Сообщения, отправленные в этом временном интервале, могут просматривать все, у кого есть пригласительная ссылка.",
|
"message_retention_info": "Установить лимит на то, как долго хранить сообщения",
|
||||||
"direct_link": "Моя прямая ссылка",
|
"direct_link": "Моя прямая ссылка",
|
||||||
"title": "Сведения о комнате",
|
"title": "Сведения о комнате",
|
||||||
"created_by": "Создано {user}",
|
"created_by": "Создано {user}",
|
||||||
|
|
@ -51,13 +51,13 @@
|
||||||
"user": "{user}",
|
"user": "{user}",
|
||||||
"user_you": "{user} (вы)",
|
"user_you": "{user} (вы)",
|
||||||
"show_all": "Показать все >",
|
"show_all": "Показать все >",
|
||||||
"export_room": "Экспорт чата",
|
"export_room": "Сохранить чат",
|
||||||
"moderation": "Модерация",
|
"moderation": "Модерация",
|
||||||
"room_type": "Тип комнаты",
|
"room_type": "Тип комнаты",
|
||||||
"voice_mode": "Голосовой режим",
|
"voice_mode": "Голосовой режим",
|
||||||
"voice_mode_info": "Переключает интерфейс чата в режим \"прослушивания и записи\"",
|
"voice_mode_info": "Переключает интерфейс чата в режим \"прослушивания и записи\"",
|
||||||
"file_mode": "Файловый режим",
|
"file_mode": "Файловый режим",
|
||||||
"download_chat": "Скачать чат",
|
"download_chat": "Сохранить чат",
|
||||||
"read_only_room_info": "Отправлять сообщения в комнате могут только администраторы и модераторы.",
|
"read_only_room_info": "Отправлять сообщения в комнате могут только администраторы и модераторы.",
|
||||||
"message_retention_4_week": "4 недели",
|
"message_retention_4_week": "4 недели",
|
||||||
"shared_room_number": "Вы делите {count} комнат с {name}",
|
"shared_room_number": "Вы делите {count} комнат с {name}",
|
||||||
|
|
@ -71,7 +71,18 @@
|
||||||
"report_reason": "Причина",
|
"report_reason": "Причина",
|
||||||
"report": "Сообщить об ошибке",
|
"report": "Сообщить об ошибке",
|
||||||
"accept_knock": "Принять",
|
"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": {
|
"file_mode": {
|
||||||
"sending": "Отправка",
|
"sending": "Отправка",
|
||||||
|
|
@ -87,7 +98,21 @@
|
||||||
"choose_files": "Выбрать файлы",
|
"choose_files": "Выбрать файлы",
|
||||||
"quality": "Качество",
|
"quality": "Качество",
|
||||||
"original": "Оригинал",
|
"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": {
|
"global": {
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
|
|
@ -95,9 +120,9 @@
|
||||||
"show_more": "Показать больше",
|
"show_more": "Показать больше",
|
||||||
"time": {
|
"time": {
|
||||||
"recently": "только что",
|
"recently": "только что",
|
||||||
"minutes": "{n} минуту назад | {n} минут назад | {n} минут назад",
|
"minutes": "1 минуту назад | {n} минут назад",
|
||||||
"hours": "{n} час назад | {n} часов назад | {n} часов назад",
|
"hours": "1 час назад | {n} часов назад",
|
||||||
"days": "{n} день назад | {n} дней назад | {n} дней назад"
|
"days": "1 день назад | {n} дней назад"
|
||||||
},
|
},
|
||||||
"notify": "Оповестить",
|
"notify": "Оповестить",
|
||||||
"password_didnot_match": "Пароли не совпадают",
|
"password_didnot_match": "Пароли не совпадают",
|
||||||
|
|
@ -106,7 +131,8 @@
|
||||||
"click_to_remove": "Нажмите, чтобы удалить",
|
"click_to_remove": "Нажмите, чтобы удалить",
|
||||||
"close": "закрыть",
|
"close": "закрыть",
|
||||||
"different_browser_title": "Попробуйте другой браузер",
|
"different_browser_title": "Попробуйте другой браузер",
|
||||||
"different_browser_content": "Некоторые функции могут работать некорректно. Скопируйте ссылку и откройте её в другом браузере."
|
"different_browser_content": "Некоторые функции могут работать некорректно. Скопируйте ссылку и откройте её в другом браузере.",
|
||||||
|
"retry": "Обновить"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"download_all": "Скачать все",
|
"download_all": "Скачать все",
|
||||||
|
|
@ -167,12 +193,15 @@
|
||||||
"replying_to": "В ответ {user}",
|
"replying_to": "В ответ {user}",
|
||||||
"your_message": "Ваше сообщение...",
|
"your_message": "Ваше сообщение...",
|
||||||
"scale_image": "Масштабировать изображение",
|
"scale_image": "Масштабировать изображение",
|
||||||
"time_ago": "Сегодня | Вчера | {count} дней назад | {count} дней назад",
|
"time_ago": "Сегодня | Вчера | {count} дней назад",
|
||||||
"not_allowed_to_send": "Только администраторы и модераторы могут отправлять сообщения в комнате",
|
"not_allowed_to_send": "Только администраторы и модераторы могут отправлять сообщения в комнате",
|
||||||
"reaction_count_more": "{reactionCount} больше",
|
"reaction_count_more": "{reactionCount} больше",
|
||||||
"seen_by_count": "Не просмотрено | Просмотрено 1 участником | Просмотрено {count} участниками",
|
"seen_by_count": "Не просмотрено | Просмотрено 1 участником | Просмотрено {count} участниками",
|
||||||
"send_attachements_dialog_title": "Вы хотите отправить следующие вложения?",
|
"send_attachements_dialog_title": "Вы хотите отправить следующие вложения?",
|
||||||
"failed_to_render": "Не удалось обработать событие"
|
"failed_to_render": "Не удалось обработать событие",
|
||||||
|
"room_upgraded": "Эта комната была модернизирована, идите {link}, чтобы присоединиться к обсуждению",
|
||||||
|
"room_upgraded_link": "сюда",
|
||||||
|
"room_upgraded_view_old": "Этот номер был модернизирован. Нажмите {link}, чтобы просмотреть старые сообщения"
|
||||||
},
|
},
|
||||||
"new_room": {
|
"new_room": {
|
||||||
"create": "Создать",
|
"create": "Создать",
|
||||||
|
|
@ -182,7 +211,7 @@
|
||||||
"name_room": "Название комнаты",
|
"name_room": "Название комнаты",
|
||||||
"join_permissions_info": "Эти разрешения определяют, как присоединиться к комнате и как пригласить новых участников. Они могут быть изменены в любое время.",
|
"join_permissions_info": "Эти разрешения определяют, как присоединиться к комнате и как пригласить новых участников. Они могут быть изменены в любое время.",
|
||||||
"set_join_permissions": "Установка разрешений на присоединение",
|
"set_join_permissions": "Установка разрешений на присоединение",
|
||||||
"room_topic": "Добавьте описание, если хотите",
|
"room_topic": "Добавить описание, если хотите",
|
||||||
"join_permissions": "Разрешения на присоединение",
|
"join_permissions": "Разрешения на присоединение",
|
||||||
"get_link": "Получить ссылку",
|
"get_link": "Получить ссылку",
|
||||||
"add_people": "Добавить людей",
|
"add_people": "Добавить людей",
|
||||||
|
|
@ -193,8 +222,9 @@
|
||||||
"invite_info": "Только добавленные участники",
|
"invite_info": "Только добавленные участники",
|
||||||
"invite_description": "Выберите из списка или выполните поиск по ID пользователя",
|
"invite_description": "Выберите из списка или выполните поиск по ID пользователя",
|
||||||
"status_creating": "Создание комнаты",
|
"status_creating": "Создание комнаты",
|
||||||
"status_avatar": "Загрузка аватара: {count}",
|
"status_avatar": "Загрузка аватар",
|
||||||
"room_name_limit_error_msg": "Не более 50 символов"
|
"room_name_limit_error_msg": "Не более 50 символов",
|
||||||
|
"title": "Новый групповой чат"
|
||||||
},
|
},
|
||||||
"room_info_sheet": {
|
"room_info_sheet": {
|
||||||
"view_details": "Подробнее",
|
"view_details": "Подробнее",
|
||||||
|
|
@ -244,13 +274,18 @@
|
||||||
"loading": "Загрузка {appName}",
|
"loading": "Загрузка {appName}",
|
||||||
"user_revoke_moderator": "Отозвать статус модератора",
|
"user_revoke_moderator": "Отозвать статус модератора",
|
||||||
"user_make_moderator": "Назначить модератором",
|
"user_make_moderator": "Назначить модератором",
|
||||||
"user_make_admin": "Назначить администратором"
|
"user_make_admin": "Назначить администратором",
|
||||||
|
"user_revoke_admin": "Отменить права администратора",
|
||||||
|
"pin": "Штифт",
|
||||||
|
"unpin": "Открепить пост",
|
||||||
|
"cancel_knock": "Отменить стук",
|
||||||
|
"upgrade": "Обновление"
|
||||||
},
|
},
|
||||||
"room": {
|
"room": {
|
||||||
"leave": "Выйти",
|
"leave": "Выйти",
|
||||||
"room_list_invites": "Приглашения",
|
"room_list_invites": "Приглашения",
|
||||||
"room_list_rooms": "Комнаты",
|
"room_list_rooms": "Комнаты",
|
||||||
"unseen_messages": "У вас нет непросмотренных сообщений | У вас {count} непросмотренное сообщение | У вас {count} непросмотренных сообщений | У вас {count} непросмотренных сообщений",
|
"unseen_messages": "У вас нет непросмотренных сообщений | У вас 1 непросмотренное сообщение | У вас {count} непросмотренных сообщений",
|
||||||
"members": "нет участников | 1 участник | {count} участников",
|
"members": "нет участников | 1 участник | {count} участников",
|
||||||
"purge_set_room_state": "Установка статуса комнаты",
|
"purge_set_room_state": "Установка статуса комнаты",
|
||||||
"purge_removing_members": "Удаление участников ({count} из {total})",
|
"purge_removing_members": "Удаление участников ({count} из {total})",
|
||||||
|
|
@ -259,7 +294,9 @@
|
||||||
"room_topic_required": "Описание комнаты обязательно",
|
"room_topic_required": "Описание комнаты обязательно",
|
||||||
"invitations": "У вас нет приглашений | У вас 1 приглашение | У вас {count} приглашений",
|
"invitations": "У вас нет приглашений | У вас 1 приглашение | У вас {count} приглашений",
|
||||||
"purge_redacting_events": "Удаление сообщений ({count} из {total})",
|
"purge_redacting_events": "Удаление сообщений ({count} из {total})",
|
||||||
"room_list_new_messages": "{count} новых сообщений"
|
"room_list_new_messages": "{count} новых сообщений",
|
||||||
|
"needs_upgrade": "Эта комната должна быть модернизирована до новой версии комнаты",
|
||||||
|
"upgrading": "Улучшенная версия комнаты"
|
||||||
},
|
},
|
||||||
"room_welcome": {
|
"room_welcome": {
|
||||||
"got_it": "Понятно!",
|
"got_it": "Понятно!",
|
||||||
|
|
@ -277,7 +314,8 @@
|
||||||
"info": "Добро пожаловать! Вот несколько вещей, которые нужно знать о вашей комнате:",
|
"info": "Добро пожаловать! Вот несколько вещей, которые нужно знать о вашей комнате:",
|
||||||
"direct_private_chat": "Личное сообщение",
|
"direct_private_chat": "Личное сообщение",
|
||||||
"info_retention_user": "🕓 Сообщения старше {time} будут удалены из истории.",
|
"info_retention_user": "🕓 Сообщения старше {time} будут удалены из истории.",
|
||||||
"info_auto_join": "Добро пожаловать в {room}.\nВы присоединяетесь как {you}."
|
"info_auto_join": "Добро пожаловать в {room}.\nВы присоединяетесь как {you}.",
|
||||||
|
"join_knock": "Люди могут попросить присоединиться к ́nocking ́."
|
||||||
},
|
},
|
||||||
"device_list": {
|
"device_list": {
|
||||||
"blocked": "Заблокированный",
|
"blocked": "Заблокированный",
|
||||||
|
|
@ -313,7 +351,8 @@
|
||||||
"language_description": "Convene доступен на многих языках.",
|
"language_description": "Convene доступен на многих языках.",
|
||||||
"dont_see_yours": "Не видите своего?",
|
"dont_see_yours": "Не видите своего?",
|
||||||
"tell_us": "Сообщите нам.",
|
"tell_us": "Сообщите нам.",
|
||||||
"display_name_required": "Отображаемое имя обязательно"
|
"display_name_required": "Отображаемое имя обязательно",
|
||||||
|
"my_rooms": "Мои комнаты"
|
||||||
},
|
},
|
||||||
"join": {
|
"join": {
|
||||||
"user_name_label": "Имя пользователя",
|
"user_name_label": "Имя пользователя",
|
||||||
|
|
@ -331,7 +370,11 @@
|
||||||
"choose_name": "Выберите себе имя",
|
"choose_name": "Выберите себе имя",
|
||||||
"status_logging_in": "Вход…",
|
"status_logging_in": "Вход…",
|
||||||
"knock": "Постучаться",
|
"knock": "Постучаться",
|
||||||
"enter_knock": "Вступить"
|
"enter_knock": "Вступить",
|
||||||
|
"knock_reason": "Причина нока (факультативно)",
|
||||||
|
"status_knocking": "Стучал...",
|
||||||
|
"accept_ua": "Я согласен с {agreement}",
|
||||||
|
"ua": "пользовательское соглашение"
|
||||||
},
|
},
|
||||||
"leave": {
|
"leave": {
|
||||||
"title_invite": "Вы уверены, что хотите выйти?",
|
"title_invite": "Вы уверены, что хотите выйти?",
|
||||||
|
|
@ -388,7 +431,8 @@
|
||||||
"identity_temporary": "{displayName}",
|
"identity_temporary": "{displayName}",
|
||||||
"want_more": "Хотите больше?",
|
"want_more": "Хотите больше?",
|
||||||
"powered_by": "Эта комната использует {product}. Узнайте больше по ссылке {productLink} или продолжайте, и создайте другую комнату!",
|
"powered_by": "Эта комната использует {product}. Узнайте больше по ссылке {productLink} или продолжайте, и создайте другую комнату!",
|
||||||
"new_room": "Новая комната"
|
"new_room": "Новая комната",
|
||||||
|
"review_ua": "Обзор Соглашения об Пользователе Сервиса"
|
||||||
},
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"title": "Добавить друзей",
|
"title": "Добавить друзей",
|
||||||
|
|
@ -421,7 +465,6 @@
|
||||||
"title": "Получено новое сообщение",
|
"title": "Получено новое сообщение",
|
||||||
"periodicSync_new_msg_reminder": "У вас могут быть новые сообщения"
|
"periodicSync_new_msg_reminder": "У вас могут быть новые сообщения"
|
||||||
},
|
},
|
||||||
"language_display_name": "Русский",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Convene",
|
"name": "Convene",
|
||||||
"tag_line": "Просто подключись"
|
"tag_line": "Просто подключись"
|
||||||
|
|
@ -439,7 +482,9 @@
|
||||||
"info": "Транслируйте новости или знания в любом формате – видео, подкаст, текст, картинки или PDF-файлы.",
|
"info": "Транслируйте новости или знания в любом формате – видео, подкаст, текст, картинки или PDF-файлы.",
|
||||||
"channel_name": "Дайте название вашему каналу",
|
"channel_name": "Дайте название вашему каналу",
|
||||||
"channel_topic": "Опишите его",
|
"channel_topic": "Опишите его",
|
||||||
"error_channel": "Не удалось создать канал"
|
"error_channel": "Не удалось создать канал",
|
||||||
|
"channel_topic_label": "Ваше описание будет отображаться, когда люди присоединятся к вашему каналу.",
|
||||||
|
"status_creating": "Создание канала"
|
||||||
},
|
},
|
||||||
"room_export": {
|
"room_export": {
|
||||||
"fetched_n_events": "Найдено {count} событий",
|
"fetched_n_events": "Найдено {count} событий",
|
||||||
|
|
@ -450,6 +495,23 @@
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"room_type_channel_name": "Канал",
|
"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": "මකන්න",
|
"delete": "මකන්න",
|
||||||
"done": "අහවරයි"
|
"done": "අහවරයි"
|
||||||
},
|
},
|
||||||
"language_display_name": "ඉංග්රීසි",
|
|
||||||
"message": {
|
"message": {
|
||||||
"download_progress": "{percentage}% බාගත වී ඇත",
|
"download_progress": "{percentage}% බාගත වී ඇත",
|
||||||
"file_prefix": "ගොනුව: ",
|
"file_prefix": "ගොනුව: ",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"language_display_name": "İngilizce",
|
|
||||||
"global": {
|
"global": {
|
||||||
"save": "Kaydet",
|
"save": "Kaydet",
|
||||||
"show_less": "Ayrıntıları gizle",
|
"show_less": "Ayrıntıları gizle",
|
||||||
|
|
@ -17,7 +16,8 @@
|
||||||
"add_reaction": "Tepki ekle",
|
"add_reaction": "Tepki ekle",
|
||||||
"click_to_remove": "Kaldırmak için tıklayın",
|
"click_to_remove": "Kaldırmak için tıklayın",
|
||||||
"different_browser_title": "Başka bir tarayıcı kullan",
|
"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": {
|
"menu": {
|
||||||
"logout": "Oturumu kapat",
|
"logout": "Oturumu kapat",
|
||||||
|
|
@ -42,7 +42,8 @@
|
||||||
"loading": "{appName} yükleniyor",
|
"loading": "{appName} yükleniyor",
|
||||||
"user_make_admin": "Yönetici yap",
|
"user_make_admin": "Yönetici yap",
|
||||||
"user_make_moderator": "Sorumlu yap",
|
"user_make_moderator": "Sorumlu yap",
|
||||||
"user_revoke_moderator": "Sorumluluğu kaldır"
|
"user_revoke_moderator": "Sorumluluğu kaldır",
|
||||||
|
"upgrade": "Yükselt"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"file": "Dosya",
|
"file": "Dosya",
|
||||||
|
|
@ -108,7 +109,8 @@
|
||||||
"reaction_count_more": "{reactionCount} diğer",
|
"reaction_count_more": "{reactionCount} diğer",
|
||||||
"send_attachements_dialog_title": "Şu eklentileri göndermek istiyor musunuz?",
|
"send_attachements_dialog_title": "Şu eklentileri göndermek istiyor musunuz?",
|
||||||
"download_all": "Hepsini indir",
|
"download_all": "Hepsini indir",
|
||||||
"failed_to_render": "Eylem oluşturulamadı"
|
"failed_to_render": "Eylem oluşturulamadı",
|
||||||
|
"room_upgraded_link": "buradan"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"accept_terms": "Kabul et",
|
"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!",
|
"files_sent_with_note": "1 dosya not ile birlikte gönderildi! | {count} dosya not ile birlikte gönderildi!",
|
||||||
"quality": "Kodlama",
|
"quality": "Kodlama",
|
||||||
"original": "Orijinal",
|
"original": "Orijinal",
|
||||||
"learn_more": "Daha fazla bilgi edin"
|
"learn_more": "Daha fazla bilgi edin",
|
||||||
|
"cc_source": "Kaynak",
|
||||||
|
"cc_location": "Konum"
|
||||||
},
|
},
|
||||||
"room": {
|
"room": {
|
||||||
"leave": "Ayrıl",
|
"leave": "Ayrıl",
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@
|
||||||
"file": "ھۆججەت",
|
"file": "ھۆججەت",
|
||||||
"files": "ھۆججەت"
|
"files": "ھۆججەت"
|
||||||
},
|
},
|
||||||
"language_display_name": "ئۇيغۇرچە",
|
|
||||||
"new_room": {
|
"new_room": {
|
||||||
"link_copied": "ئۇلىنىش كۆچۈرۈلدى!",
|
"link_copied": "ئۇلىنىش كۆچۈرۈلدى!",
|
||||||
"status_avatar": "باش سۈرىتى يۈكلىنىۋاتىدۇ: {count}",
|
"status_avatar": "باش سۈرىتى يۈكلىنىۋاتىدۇ: {count}",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"click_to_remove": "Натисніть, щоб видалити",
|
"click_to_remove": "Натисніть, щоб видалити",
|
||||||
"show_less": "Показувати менше",
|
"show_less": "Показувати менше",
|
||||||
"show_more": "Показати більше",
|
"show_more": "Показати більше",
|
||||||
"notify": "Повідомити"
|
"notify": "Повідомити",
|
||||||
|
"retry": "Повторити спробу"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"start_private_chat": "Пряме повідомлення з цим користувачем",
|
"start_private_chat": "Пряме повідомлення з цим користувачем",
|
||||||
|
|
@ -48,7 +49,6 @@
|
||||||
"user_revoke_admin": "Відкинути адміністратора",
|
"user_revoke_admin": "Відкинути адміністратора",
|
||||||
"upgrade": "Оновлення"
|
"upgrade": "Оновлення"
|
||||||
},
|
},
|
||||||
"language_display_name": "Англійська",
|
|
||||||
"project": {
|
"project": {
|
||||||
"name": "Скликати",
|
"name": "Скликати",
|
||||||
"tag_line": ",Просто підключіться"
|
"tag_line": ",Просто підключіться"
|
||||||
|
|
@ -120,7 +120,8 @@
|
||||||
"download_all": "Завантажити усі",
|
"download_all": "Завантажити усі",
|
||||||
"room_upgraded": "Цю кімнату оновлено, перейдіть за посиланням {link}, щоб знову приєднатися до обговорення",
|
"room_upgraded": "Цю кімнату оновлено, перейдіть за посиланням {link}, щоб знову приєднатися до обговорення",
|
||||||
"room_upgraded_link": "тут",
|
"room_upgraded_link": "тут",
|
||||||
"room_upgraded_view_old": "Цю кімнату оновлено. Натисніть {link}, щоб переглянути старі повідомлення"
|
"room_upgraded_view_old": "Цю кімнату оновлено. Натисніть {link}, щоб переглянути старі повідомлення",
|
||||||
|
"download_media": "Завантажити медіафайли"
|
||||||
},
|
},
|
||||||
"room": {
|
"room": {
|
||||||
"invitations": "У вас немає запрошень | У вас є 1 запрошення | У вас є {count} запрошень",
|
"invitations": "У вас немає запрошень | У вас є 1 запрошення | У вас є {count} запрошень",
|
||||||
|
|
@ -313,7 +314,11 @@
|
||||||
"leave": "Залишити"
|
"leave": "Залишити"
|
||||||
},
|
},
|
||||||
"logout": {
|
"logout": {
|
||||||
"confirm_text": "Ви впевнені, що хочете вийти?"
|
"confirm_text": "Ви впевнені, що хочете вийти?",
|
||||||
|
"copy_credentials": "Скопіювати ім'я користувача та пароль",
|
||||||
|
"copied_credentials": "Скопійовано ім'я користувача та пароль",
|
||||||
|
"copied_credentials_value": "Ім'я користувача: {userId} \nПароль: {password}",
|
||||||
|
"copy_credentials_desc": "Для відновлення доступу до чатів з нового пристрою або браузера потрібні ваші ім’я користувача та пароль. Рекомендуємо зберігати ці облікові дані в безпечному місці."
|
||||||
},
|
},
|
||||||
"purge_room": {
|
"purge_room": {
|
||||||
"title": "Видалити кімнату?",
|
"title": "Видалити кімнату?",
|
||||||
|
|
@ -487,7 +492,6 @@
|
||||||
"files": "Файли",
|
"files": "Файли",
|
||||||
"quality": "Якість",
|
"quality": "Якість",
|
||||||
"original": "Оригінальний",
|
"original": "Оригінальний",
|
||||||
"content_credentials": "Облікові дані вмісту",
|
|
||||||
"learn_more": "Дізнатися більше",
|
"learn_more": "Дізнатися більше",
|
||||||
"choose_files": "Вибрати файли",
|
"choose_files": "Вибрати файли",
|
||||||
"any_file_format_accepted": "Приймається будь-який формат файлу",
|
"any_file_format_accepted": "Приймається будь-який формат файлу",
|
||||||
|
|
@ -501,10 +505,34 @@
|
||||||
"metadata_info_compressed": "Стиснення зображення автоматично виключає його метадані.",
|
"metadata_info_compressed": "Стиснення зображення автоматично виключає його метадані.",
|
||||||
"metadata_info_original": "Спільний доступ до оригіналу автоматично включає його метадані.",
|
"metadata_info_original": "Спільний доступ до оригіналу автоматично включає його метадані.",
|
||||||
"exif_data": "Дані Exif",
|
"exif_data": "Дані Exif",
|
||||||
"content_credentials_info": "Для цього медіа доступна інформація про джерело або історію, яку потрібно перевірити.",
|
|
||||||
"ai_used": "Фото змінено за допомогою штучного інтелекту",
|
"ai_used": "Фото змінено за допомогою штучного інтелекту",
|
||||||
"screenshot_taken_on": "Знімок екрана зроблено {date}",
|
"screenshot_taken_on": "Знімок екрана зроблено {date}",
|
||||||
"captured_with_camera": "Знято камерою",
|
"captured_with_camera": "Знято справжньою камерою. ",
|
||||||
"old_photo": "Фотографія старша за 3 місяці"
|
"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} 媒体项目。",
|
"sent_media": "已发送{count} 媒体项目。",
|
||||||
"room_upgraded": "此聊天室已升级,请前往{link}重新加入讨论",
|
"room_upgraded": "此聊天室已升级,请前往{link}重新加入讨论",
|
||||||
"room_upgraded_view_old": "此聊天室已升级,点击{link}查看旧信息",
|
"room_upgraded_view_old": "此聊天室已升级,点击{link}查看旧信息",
|
||||||
"room_upgraded_link": "这里"
|
"room_upgraded_link": "这里",
|
||||||
|
"download_media": "下载媒体"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"login": "登录",
|
"login": "登录",
|
||||||
|
|
@ -359,7 +360,6 @@
|
||||||
"close_tab": "关闭浏览器标签",
|
"close_tab": "关闭浏览器标签",
|
||||||
"room_deleted": "聊天室已删除。"
|
"room_deleted": "聊天室已删除。"
|
||||||
},
|
},
|
||||||
"language_display_name": "简体中文",
|
|
||||||
"global": {
|
"global": {
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"password_didnot_match": "密码不匹配",
|
"password_didnot_match": "密码不匹配",
|
||||||
|
|
@ -377,10 +377,15 @@
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"notify": "通知",
|
"notify": "通知",
|
||||||
"different_browser_title": "尝试其他的浏览器",
|
"different_browser_title": "尝试其他的浏览器",
|
||||||
"different_browser_content": "某些功能可能会中断。复制链接后用另一个浏览器打开。"
|
"different_browser_content": "某些功能可能会中断。复制链接后用另一个浏览器打开。",
|
||||||
|
"retry": "重试"
|
||||||
},
|
},
|
||||||
"logout": {
|
"logout": {
|
||||||
"confirm_text": "您确定要注销吗?"
|
"confirm_text": "您确定要注销吗?",
|
||||||
|
"copy_credentials": "复制用户名和密码",
|
||||||
|
"copied_credentials": "用户名和密码已复制",
|
||||||
|
"copied_credentials_value": "用户名: {userId} \n\n密码: {password}",
|
||||||
|
"copy_credentials_desc": "您需要使用用户名和密码才能在新的设备或浏览器上重新访问您的聊天记录。我们建议您将这些凭据保存在安全的地方。"
|
||||||
},
|
},
|
||||||
"poll_create": {
|
"poll_create": {
|
||||||
"title": "创建新投票",
|
"title": "创建新投票",
|
||||||
|
|
@ -430,17 +435,21 @@
|
||||||
"files_sent": "已发送 1 个文件! | 已发送 {count} 个文件!",
|
"files_sent": "已发送 1 个文件! | 已发送 {count} 个文件!",
|
||||||
"quality": "画质",
|
"quality": "画质",
|
||||||
"original": "原来的",
|
"original": "原来的",
|
||||||
"content_credentials": "内容凭据",
|
|
||||||
"learn_more": "了解更多",
|
"learn_more": "了解更多",
|
||||||
"compressed": "压缩",
|
"compressed": "压缩",
|
||||||
"metadata_info_compressed": "压缩图像会自动排除其元数据。",
|
"metadata_info_compressed": "压缩图像会自动排除其元数据。",
|
||||||
"metadata_info_original": "分享原件将自动包含其元数据。",
|
"metadata_info_original": "分享原件将自动包含其元数据。",
|
||||||
"exif_data": "Exif 数据",
|
"exif_data": "Exif 数据",
|
||||||
"content_credentials_info": "该媒体可提供来源或历史信息以供验证。",
|
|
||||||
"ai_used": "经过 AI 修改的照片",
|
"ai_used": "经过 AI 修改的照片",
|
||||||
"screenshot_taken_on": "屏幕截图拍摄于 {date}",
|
"screenshot_taken_on": "屏幕截图拍摄于 {date}",
|
||||||
"captured_with_camera": "用相机拍摄",
|
"captured_with_camera": "用真正的相机拍摄. ",
|
||||||
"old_photo": "3 个月前的照片"
|
"old_photo": "3 个月以前的照片 ",
|
||||||
|
"screenshot": "屏幕截图. ",
|
||||||
|
"captured_screenshot": "屏幕截图. ",
|
||||||
|
"captured_screenshot_ago": "屏幕截图于{ago}前截取 ",
|
||||||
|
"cc_source": "来源",
|
||||||
|
"cc_capture_timestamp": "捕获时间戳",
|
||||||
|
"cc_location": "地点"
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
|
|
@ -506,5 +515,24 @@
|
||||||
"filedrop_name": "命名您的文件投递",
|
"filedrop_name": "命名您的文件投递",
|
||||||
"error_filedrop": "无法创建文件投递",
|
"error_filedrop": "无法创建文件投递",
|
||||||
"status_creating": "正在创建文件投递"
|
"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
|
no-gutters
|
||||||
align-content="center"
|
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-icon :size="iconSize">{{ icon }}</v-icon>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col class="align-self-center">{{ text }}</v-col>
|
<v-col class="align-self-center">{{ text }}</v-col>
|
||||||
|
|
|
||||||
|
|
@ -13,20 +13,22 @@
|
||||||
</v-fade-transition>
|
</v-fade-transition>
|
||||||
<div
|
<div
|
||||||
class="bottom-sheet-content"
|
class="bottom-sheet-content"
|
||||||
:data-state="isMove ? 'move' : state"
|
:data-state="state"
|
||||||
ref="pan"
|
ref="pan"
|
||||||
:style="{ top: `${isMove ? y : calcY()}px` }"
|
|
||||||
>
|
>
|
||||||
|
<v-container>
|
||||||
|
<v-row justify="end">
|
||||||
|
<v-col cols="1" class="text-right">
|
||||||
<v-btn
|
<v-btn
|
||||||
size="small"
|
size="small"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
@click.stop="onBackgroundClick"
|
@click.stop="onBackgroundClick"
|
||||||
class="bottom-sheet-close"
|
icon="close"
|
||||||
v-if="showCloseButton"
|
|
||||||
icon="cancel"
|
|
||||||
>
|
>
|
||||||
</v-btn>
|
</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">
|
<div ref="sheetContent" class="sheetContent">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -35,102 +37,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Hammer from "hammerjs";
|
|
||||||
|
|
||||||
export default {
|
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() {
|
data() {
|
||||||
return {
|
return {
|
||||||
mc: null,
|
state: "closed"
|
||||||
y: 0,
|
|
||||||
startY: 0,
|
|
||||||
startYCoord: 0,
|
|
||||||
isMove: false,
|
|
||||||
state: this.defaultState,
|
|
||||||
rect: {},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
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: {
|
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() {
|
open() {
|
||||||
this.setState("small");
|
this.setState("small");
|
||||||
},
|
},
|
||||||
|
|
@ -167,11 +80,13 @@ export default {
|
||||||
|
|
||||||
.sheetContent {
|
.sheetContent {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
top:20px;left:0;
|
top: 60px;
|
||||||
|
padding: 0 20px 20px 20px;
|
||||||
|
left:0;
|
||||||
right:0;
|
right:0;
|
||||||
bottom:0;
|
bottom:0;
|
||||||
overflow-y:auto;
|
overflow-y:auto;
|
||||||
padding:20px;
|
border-top: 1px solid #E1E1E1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-sheet {
|
.bottom-sheet {
|
||||||
|
|
@ -194,23 +109,9 @@ export default {
|
||||||
background-color: rgba(black, 0.4);
|
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 {
|
.bottom-sheet-content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0px;
|
top: 100px;
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
@ -218,11 +119,8 @@ export default {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.bottom-sheet-close {
|
&[data-state="small"], &[data-state="open"], &[data-state="closed"] {
|
||||||
position: absolute;
|
transition: top 0.3s ease-out;
|
||||||
right: 4px;
|
|
||||||
top: 4px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{map.get($display-breakpoints, 'lg-and-up')} {
|
@media #{map.get($display-breakpoints, 'lg-and-up')} {
|
||||||
|
|
@ -230,16 +128,4 @@ export default {
|
||||||
width: $dialog-desktop-width;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
v-on:add-files="(files) => addAttachments(files)"
|
v-on:add-files="(files) => addAttachments(files)"
|
||||||
:batch="uploadBatch"
|
:batch="uploadBatch"
|
||||||
v-on:close="closeFileMode"
|
v-on:close="closeFileMode"
|
||||||
|
v-on:reset="resetFileMode"
|
||||||
:fileModeMode="true"
|
:fileModeMode="true"
|
||||||
:defaultRootMessageText="$t('file_mode.files')"
|
:defaultRootMessageText="$t('file_mode.files')"
|
||||||
/>
|
/>
|
||||||
|
|
@ -54,8 +55,9 @@
|
||||||
v-on:addquickreaction="addQuickReaction"
|
v-on:addquickreaction="addQuickReaction"
|
||||||
v-on:addreply="addReply(selectedEvent)"
|
v-on:addreply="addReply(selectedEvent)"
|
||||||
v-on:edit="edit(selectedEvent)"
|
v-on:edit="edit(selectedEvent)"
|
||||||
v-on:redact="redact(selectedEvent)"
|
v-on:redact="showDeletePostPopup = true"
|
||||||
v-on:download="download(selectedEvent)"
|
v-on:download="download(selectedEvent)"
|
||||||
|
v-on:report="reportEvent(selectedEvent)"
|
||||||
v-on:more="
|
v-on:more="
|
||||||
isEmojiQuickReaction=true;
|
isEmojiQuickReaction=true;
|
||||||
showMoreMessageOperations({event: selectedEvent, anchor: $event.anchor})
|
showMoreMessageOperations({event: selectedEvent, anchor: $event.anchor})
|
||||||
|
|
@ -244,7 +246,7 @@
|
||||||
:title="room.name"
|
:title="room.name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BottomSheet ref="messageOperationsSheet" halfY="0.1">
|
<BottomSheet ref="messageOperationsSheet">
|
||||||
<EmojiPicker ref="emojiPicker"
|
<EmojiPicker ref="emojiPicker"
|
||||||
:native="true"
|
:native="true"
|
||||||
@select="emojiSelected"
|
@select="emojiSelected"
|
||||||
|
|
@ -296,11 +298,15 @@
|
||||||
<PurgeRoomDialog v-model="showPurgeConfirmation" :room="room" />
|
<PurgeRoomDialog v-model="showPurgeConfirmation" :room="room" />
|
||||||
|
|
||||||
<RoomExport :room="room" v-if="downloadingChat" v-on:close="downloadingChat = false" />
|
<RoomExport :room="room" v-if="downloadingChat" v-on:close="downloadingChat = false" />
|
||||||
|
<ReportRoomOrEventDialog v-model="reportingEventShown" :room="room" :eventId="reportingEventId" />
|
||||||
|
|
||||||
<!-- Heart animation -->
|
<!-- Heart animation -->
|
||||||
<div :class="['heart-wrapper', { 'is-active': heartAnimation }]" :style="hearAnimationPosition">
|
<div :class="['heart-wrapper', { 'is-active': heartAnimation }]" :style="hearAnimationPosition">
|
||||||
<div :class="['heart', { 'is-active': heartAnimation }]" />
|
<div :class="['heart', { 'is-active': heartAnimation }]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete post dialog -->
|
||||||
|
<DeletePostDialog v-model="showDeletePostPopup" v-on:deletePost="onDeletePost"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -323,6 +329,7 @@ import UserProfileDialog from "./UserProfileDialog.vue"
|
||||||
import RoomUpgradePrompt from "./messages/composition/RoomUpgradePrompt.vue";
|
import RoomUpgradePrompt from "./messages/composition/RoomUpgradePrompt.vue";
|
||||||
import BottomSheet from "./BottomSheet.vue";
|
import BottomSheet from "./BottomSheet.vue";
|
||||||
import CreatePollDialog from "./CreatePollDialog.vue";
|
import CreatePollDialog from "./CreatePollDialog.vue";
|
||||||
|
import ReportRoomOrEventDialog from "./ReportRoomOrEventDialog.vue";
|
||||||
import chatMixin, { ROOM_READ_MARKER_EVENT_PLACEHOLDER } from "./chatMixin";
|
import chatMixin, { ROOM_READ_MARKER_EVENT_PLACEHOLDER } from "./chatMixin";
|
||||||
import AudioLayout from "./AudioLayout.vue";
|
import AudioLayout from "./AudioLayout.vue";
|
||||||
import SendAttachmentsLayout from "./file_mode/SendAttachmentsLayout.vue";
|
import SendAttachmentsLayout from "./file_mode/SendAttachmentsLayout.vue";
|
||||||
|
|
@ -333,6 +340,7 @@ import MessageErrorHandler from "./MessageErrorHandler";
|
||||||
import MessageOperationsChannel from './messages/channel/MessageOperationsChannel.vue';
|
import MessageOperationsChannel from './messages/channel/MessageOperationsChannel.vue';
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import RoomExport from "./RoomExport.vue";
|
import RoomExport from "./RoomExport.vue";
|
||||||
|
import DeletePostDialog from "./DeletePostDialog.vue"
|
||||||
import EmojiPicker from 'vue3-emoji-picker';
|
import EmojiPicker from 'vue3-emoji-picker';
|
||||||
import 'vue3-emoji-picker/css';
|
import 'vue3-emoji-picker/css';
|
||||||
import emitter from 'tiny-emitter/instance';
|
import emitter from 'tiny-emitter/instance';
|
||||||
|
|
@ -393,7 +401,9 @@ export default {
|
||||||
MessageOperationsChannel,
|
MessageOperationsChannel,
|
||||||
RoomExport,
|
RoomExport,
|
||||||
EmojiPicker,
|
EmojiPicker,
|
||||||
RoomUpgradePrompt
|
RoomUpgradePrompt,
|
||||||
|
ReportRoomOrEventDialog,
|
||||||
|
DeletePostDialog
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -484,7 +494,9 @@ export default {
|
||||||
left: 0
|
left: 0
|
||||||
},
|
},
|
||||||
reverseOrder: false,
|
reverseOrder: false,
|
||||||
downloadingChat: false
|
downloadingChat: false,
|
||||||
|
reportingEventId: null,
|
||||||
|
showDeletePostPopup: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -800,6 +812,16 @@ export default {
|
||||||
'--top': this.heartPosition.top,
|
'--top': this.heartPosition.top,
|
||||||
'--left': this.heartPosition.left
|
'--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) {
|
pin(event) {
|
||||||
const eventToPin = event.parentThread ? event.parentThread : event;
|
const eventToPin = event.parentThread ? event.parentThread : event;
|
||||||
this.$matrix.setEventPinned(this.room, eventToPin, true);
|
this.$matrix.setEventPinned(this.room, eventToPin, true);
|
||||||
|
|
@ -1975,6 +2001,10 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetFileMode() {
|
||||||
|
this.uploadBatch = this.$matrix.attachmentManager.createUpload(this.room);
|
||||||
|
},
|
||||||
|
|
||||||
closeFileMode() {
|
closeFileMode() {
|
||||||
this.uploadBatch?.cancel();
|
this.uploadBatch?.cancel();
|
||||||
this.uploadBatch = undefined;
|
this.uploadBatch = undefined;
|
||||||
|
|
@ -1982,6 +2012,11 @@ export default {
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log("Error leaving", err);
|
console.log("Error leaving", err);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onDeletePost() {
|
||||||
|
this.redact(this.selectedEvent);
|
||||||
|
this.showDeletePostPopup = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@
|
||||||
<interactive-auth ref="interactiveAuth" />
|
<interactive-auth ref="interactiveAuth" />
|
||||||
|
|
||||||
<input id="user-avatar-picker" ref="useravatar" type="file" name="user-avatar"
|
<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-dialog v-model="enterRoomDialog" :width="$vuetify.display.smAndUp ? '50%' : '90%'">
|
||||||
<v-card>
|
<v-card>
|
||||||
|
|
@ -268,7 +268,8 @@ export default {
|
||||||
this.selectedProfile.image,
|
this.selectedProfile.image,
|
||||||
function (progress) {
|
function (progress) {
|
||||||
console.log("Progress: " + JSON.stringify(progress));
|
console.log("Progress: " + JSON.stringify(progress));
|
||||||
}
|
},
|
||||||
|
!this.selectedProfile.imageSelectedByUser
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -377,6 +378,7 @@ export default {
|
||||||
handlePickedUserAvatar(event) {
|
handlePickedUserAvatar(event) {
|
||||||
util.loadAvatarFromFile(event, (image) => {
|
util.loadAvatarFromFile(event, (image) => {
|
||||||
this.selectedProfile.image = 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>
|
<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" />
|
<LoadProgress class="image-with-progress__progress" v-if="loadingProgress != undefined && loadingProgress >= 0 && loadingProgress < 100" :percentage="loadingProgress" />
|
||||||
</v-img>
|
</v-img>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -12,10 +13,10 @@ import * as sdk from "matrix-js-sdk";
|
||||||
import logoMixin from "./logoMixin";
|
import logoMixin from "./logoMixin";
|
||||||
import LoadProgress from "./LoadProgress.vue";
|
import LoadProgress from "./LoadProgress.vue";
|
||||||
import { VImg } from "vuetify/components/VImg";
|
import { VImg } from "vuetify/components/VImg";
|
||||||
|
import { emit } from "process";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ImageWithProgress",
|
name: "ImageWithProgress",
|
||||||
extends: VImg,
|
|
||||||
components: { LoadProgress },
|
components: { LoadProgress },
|
||||||
props: {
|
props: {
|
||||||
loadingProgress: {
|
loadingProgress: {
|
||||||
|
|
@ -28,6 +29,14 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
methods: {
|
||||||
|
loaded() {
|
||||||
|
this.$emit('loaded', this.$refs.image);
|
||||||
|
},
|
||||||
|
handleResize() {
|
||||||
|
this.loaded();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input id="room-avatar-picker" ref="avatar" type="file" name="avatar" @change="handlePickedAvatar($event)"
|
<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">
|
<div class="join-lang">
|
||||||
<h3 class="mb-2">{{ $t("profile.select_language") }}</h3>
|
<h3 class="mb-2">{{ $t("profile.select_language") }}</h3>
|
||||||
|
|
@ -412,7 +412,7 @@ export default {
|
||||||
console.log("Join: Updating avatar");
|
console.log("Join: Updating avatar");
|
||||||
return util.setAvatar(this.$matrix, this.selectedProfile.image, function (progress) {
|
return util.setAvatar(this.$matrix, this.selectedProfile.image, function (progress) {
|
||||||
console.log("Progress: " + JSON.stringify(progress));
|
console.log("Progress: " + JSON.stringify(progress));
|
||||||
});
|
}, !this.selectedProfile.imageSelectedByUser);
|
||||||
}
|
}
|
||||||
}.bind(this)
|
}.bind(this)
|
||||||
)
|
)
|
||||||
|
|
@ -480,6 +480,7 @@ export default {
|
||||||
handlePickedAvatar(event) {
|
handlePickedAvatar(event) {
|
||||||
util.loadAvatarFromFile(event, (image) => {
|
util.loadAvatarFromFile(event, (image) => {
|
||||||
this.selectedProfile.image = image;
|
this.selectedProfile.image = image;
|
||||||
|
this.selectedProfile.imageSelectedByUser = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,22 @@
|
||||||
@click:outside="$emit('onOutsideLogoutPopupClicked')">
|
@click:outside="$emit('onOutsideLogoutPopupClicked')">
|
||||||
<div class="dialog-content text-center">
|
<div class="dialog-content text-center">
|
||||||
<h2 class="dialog-title">{{ $t("logout.confirm_text") }}</h2>
|
<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-container fluid>
|
||||||
<v-row cols="12">
|
<v-row cols="12">
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
|
|
@ -21,6 +37,7 @@
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import profileInfoMixin from "./profileInfoMixin";
|
import profileInfoMixin from "./profileInfoMixin";
|
||||||
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "LogoutRoomDialog",
|
name: "LogoutRoomDialog",
|
||||||
|
|
@ -31,5 +48,35 @@ export default {
|
||||||
default: false,
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
<v-dialog v-model="showDialog" content-class="more-menu-popup" class="ma-0 pa-0">
|
<v-dialog v-model="showDialog" content-class="more-menu-popup" class="ma-0 pa-0">
|
||||||
<div class="popup-wrapper">
|
<div class="popup-wrapper">
|
||||||
<v-card variant="flat">
|
<v-card variant="flat">
|
||||||
<v-card-text>
|
<v-card-text class="ma-0 pa-2">
|
||||||
|
<v-container class="mt-0 pa-0 action-row-container-no-dividers">
|
||||||
<v-container class="mt-0 pa-0 pt-3 pb-3 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()" />
|
<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">
|
<v-row v-if="showProfile" class="profile-row clickable" @click="viewProfile" no-gutters align-content="center">
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
type="file"
|
type="file"
|
||||||
name="avatar"
|
name="avatar"
|
||||||
@change="handlePickedAvatar($event)"
|
@change="handlePickedAvatar($event)"
|
||||||
accept="image/*"
|
:accept="supportedAvatarImageTypes"
|
||||||
class="d-none"
|
class="d-none"
|
||||||
/>
|
/>
|
||||||
</v-avatar>
|
</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"
|
type="file"
|
||||||
name="roomAvatar"
|
name="roomAvatar"
|
||||||
@change="handleRoomPickedAvatar($event)"
|
@change="handleRoomPickedAvatar($event)"
|
||||||
accept="image/*"
|
:accept="supportedAvatarImageTypes"
|
||||||
class="d-none"
|
class="d-none"
|
||||||
/>
|
/>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
|
|
|
||||||
|
|
@ -384,7 +384,7 @@ export default {
|
||||||
|
|
||||||
const mime = blob.type;
|
const mime = blob.type;
|
||||||
|
|
||||||
if (mime.startsWith("image/")) {
|
if (util.isSupportedImageType(mime)) {
|
||||||
var extension = ".png";
|
var extension = ".png";
|
||||||
switch (mime) {
|
switch (mime) {
|
||||||
case "image/jpeg":
|
case "image/jpeg":
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,7 @@
|
||||||
v-on:message-retention-update="onMessageRetention"
|
v-on:message-retention-update="onMessageRetention"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReportRoomDialog
|
<ReportRoomOrEventDialog
|
||||||
v-model="showReportDialog"
|
v-model="showReportDialog"
|
||||||
:room="room"
|
:room="room"
|
||||||
/>
|
/>
|
||||||
|
|
@ -364,7 +364,7 @@
|
||||||
import LeaveRoomDialog from "../components/LeaveRoomDialog";
|
import LeaveRoomDialog from "../components/LeaveRoomDialog";
|
||||||
import PurgeRoomDialog from "../components/PurgeRoomDialog";
|
import PurgeRoomDialog from "../components/PurgeRoomDialog";
|
||||||
import MessageRetentionDialog from "../components/MessageRetentionDialog";
|
import MessageRetentionDialog from "../components/MessageRetentionDialog";
|
||||||
import ReportRoomDialog from "../components/ReportRoomDialog";
|
import ReportRoomOrEventDialog from "../components/ReportRoomOrEventDialog";
|
||||||
import RoomExport from "../components/RoomExport";
|
import RoomExport from "../components/RoomExport";
|
||||||
import RoomAvatarPicker from "../components/RoomAvatarPicker";
|
import RoomAvatarPicker from "../components/RoomAvatarPicker";
|
||||||
import CopyLink from "../components/CopyLink.vue"
|
import CopyLink from "../components/CopyLink.vue"
|
||||||
|
|
@ -382,7 +382,7 @@ export default {
|
||||||
LeaveRoomDialog,
|
LeaveRoomDialog,
|
||||||
PurgeRoomDialog,
|
PurgeRoomDialog,
|
||||||
MessageRetentionDialog,
|
MessageRetentionDialog,
|
||||||
ReportRoomDialog,
|
ReportRoomOrEventDialog,
|
||||||
UserProfileDialog,
|
UserProfileDialog,
|
||||||
RoomExport,
|
RoomExport,
|
||||||
RoomAvatarPicker,
|
RoomAvatarPicker,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<BottomSheet
|
<BottomSheet
|
||||||
class="room-info-bottom-sheet"
|
class="room-info-bottom-sheet"
|
||||||
:halfY="0.12"
|
|
||||||
ref="sheet"
|
ref="sheet"
|
||||||
:showCloseButton="false"
|
|
||||||
>
|
>
|
||||||
<div class="room-info-sheet" ref="roomInfoSheetContent">
|
<div class="room-info-sheet" ref="roomInfoSheetContent">
|
||||||
<room-list v-on:close="close" v-on:newroom="createRoom" :showCreate="!$config.hide_add_room_on_home" />
|
<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>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.sheetContent {
|
|
||||||
top: 40px !important;
|
|
||||||
padding: 0 20px 20px 20px !important;
|
|
||||||
}
|
|
||||||
.sticker-picker {
|
.sticker-picker {
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,52 @@
|
||||||
<template>
|
<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">
|
<div class="detail-title">
|
||||||
{{ t("cc.content_credentials") }}
|
{{ t("cc.details") }}
|
||||||
<v-icon>$vuetify.icons.ic_cr</v-icon>
|
<v-icon v-if="hasC2PA">$vuetify.icons.ic_cr</v-icon>
|
||||||
</div>
|
|
||||||
<div class="detail-subtitle" v-if="hasC2PA">
|
|
||||||
{{ t("cc.content_credentials_info") }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cc-detail-info" v-if="infoText !== undefined">
|
<CCProperty v-for="d in details" :icon="d.icon" :title="d.title" :value="d.value">
|
||||||
{{ infoText }}
|
<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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n";
|
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 { computed, ref, Ref, watch } from "vue";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import CCProperty from "./CCProperty.vue";
|
import CCProperty, { CCPropertyProps } from "./CCProperty.vue";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import { interventionText } from "./intervention";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
|
@ -41,52 +54,77 @@ const { t } = useI18n();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
proof?: Proof;
|
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 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(() => {
|
const hasC2PA = computed(() => {
|
||||||
return props.proof?.integrity?.c2pa !== undefined;
|
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(
|
watch(
|
||||||
props,
|
props,
|
||||||
() => {
|
() => {
|
||||||
infoText.value = undefined;
|
infoText.value = props.metadata ?
|
||||||
creationDate.value = undefined;
|
interventionText(t, mediaMetadataToMediaInterventionFlags(props.metadata)) :
|
||||||
|
props.interventionFlags ? interventionText(t, props.interventionFlags) : undefined;
|
||||||
try {
|
updateDetails();
|
||||||
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) {}
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
export type CCPropertyProps = {
|
||||||
icon: string;
|
icon: string;
|
||||||
title: string;
|
title: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
}>();
|
}
|
||||||
|
const props = defineProps<CCPropertyProps>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<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__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>
|
<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"
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="attachment-info">
|
<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">
|
<div class="attachment-info__quality__title">
|
||||||
{{ t("file_mode.quality") }}
|
{{ t("file_mode.quality") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-center">
|
<div class="d-flex justify-center">
|
||||||
<div @click="attachment.useScaled = true" :class="{ 'attachment-info__quality__class': true, selected: attachment.useScaled, clickable: true }">
|
<div @click="attachment.useCompressed = true" :class="{ 'attachment-info__quality__class': true, selected: attachment.useCompressed, clickable: true }">
|
||||||
<v-icon>$vuetify.icons.ic_scaled</v-icon>
|
<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-name">{{ t("file_mode.compressed") }}</div>
|
||||||
<div class="attachment-info__quality__class-size">
|
<div class="attachment-info__quality__class-size">
|
||||||
<span>{{ attachment.scaledDimensions?.width }} x {{ attachment.scaledDimensions?.height }}</span>
|
<span>{{ attachment.compressedDimensions?.width }} x {{ attachment.compressedDimensions?.height }}</span>
|
||||||
<span> ({{ formatBytes(attachment.scaledFile.size) }})</span>
|
<span> ({{ formatBytes(attachment.compressedFile.size) }})</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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-name">{{ t("file_mode.original") }}</div>
|
||||||
<div class="attachment-info__quality__class-size">
|
<div class="attachment-info__quality__class-size">
|
||||||
|
|
@ -24,12 +24,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="attachment-info__quality__info">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<C2PAInfo class="attachment-info__detail-box" v-if="showC2PAInfo" :proof="attachment.proof" :flags="attachment.proofHintFlags" />
|
<C2PAInfo class="attachment-info__detail-box" v-if="showC2PAInfo || hasExif" :proof="attachment.proof" :metadata="attachment.mediaMetadata" :showFlagsOnly="attachment.useCompressed" />
|
||||||
<EXIFInfo class="attachment-info__detail-box" v-if="hasExif" :exif="attachment.proof?.integrity?.exif" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -37,7 +36,6 @@
|
||||||
import { Attachment } from "../../models/attachment";
|
import { Attachment } from "../../models/attachment";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import C2PAInfo from "../content-credentials/C2PAInfo.vue";
|
import C2PAInfo from "../content-credentials/C2PAInfo.vue";
|
||||||
import EXIFInfo from "../content-credentials/EXIFInfo.vue";
|
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
|
@ -50,7 +48,7 @@ const { attachment } = defineProps<{
|
||||||
//console.error("ATTACHMENT", attachment.proof);
|
//console.error("ATTACHMENT", attachment.proof);
|
||||||
|
|
||||||
const showC2PAInfo = computed(() => {
|
const showC2PAInfo = computed(() => {
|
||||||
return attachment.proof?.integrity?.c2pa !== undefined || attachment.proofHintFlags !== undefined;
|
return attachment.proof?.integrity?.c2pa !== undefined || attachment.mediaMetadata !== undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasExif = computed(() => {
|
const hasExif = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,27 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="attachment-info">
|
<div class="attachment-info">
|
||||||
|
<div class="attachment-info__content">
|
||||||
<div v-if="loadingProof">
|
<div v-if="loadingProof">
|
||||||
<div style="font-size: 0.7em; opacity: 0.7">
|
<div style="font-size: 0.7em; opacity: 0.7">
|
||||||
<v-progress-circular indeterminate class="mb-0"></v-progress-circular>
|
<v-progress-circular indeterminate class="mb-0"></v-progress-circular>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-if="metaStripped" class="cc-detail-info white-space-pre">{{ t("cc.metadata-stripped") }}</div>
|
<C2PAInfo :proof="attachment?.proof" :metadata="attachment?.mediaMetadata" :interventionFlags="attachment?.mediaInterventionFlags" :metaStripped="metaStripped" />
|
||||||
<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" />
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import C2PAInfo from "../content-credentials/C2PAInfo.vue";
|
import C2PAInfo from "../content-credentials/C2PAInfo.vue";
|
||||||
import EXIFInfo from "../content-credentials/EXIFInfo.vue";
|
import { onMounted, Ref, ref } from "vue";
|
||||||
import { computed, onMounted, Ref, ref } from "vue";
|
|
||||||
import { EventAttachment } from "../../models/eventAttachment";
|
import { EventAttachment } from "../../models/eventAttachment";
|
||||||
import proofmode from "../../plugins/proofmode";
|
import proofmode from "../../plugins/proofmode";
|
||||||
import { extractProofHintFlags } from "../../models/proof";
|
import { extractMediaMetadata } from "../../models/proof";
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const { attachment } = defineProps<{
|
const { attachment } = defineProps<{
|
||||||
attachment?: EventAttachment;
|
attachment?: EventAttachment;
|
||||||
|
|
@ -31,8 +30,18 @@ const { attachment } = defineProps<{
|
||||||
const loadingProof: Ref<boolean> = ref(false);
|
const loadingProof: Ref<boolean> = ref(false);
|
||||||
const metaStripped: 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(() => {
|
onMounted(() => {
|
||||||
if (attachment?.proofHintFlags && attachment.proof === undefined) {
|
if ((attachment?.mediaMetadata || attachment?.mediaInterventionFlags) && attachment.proof === undefined) {
|
||||||
const a = attachment;
|
const a = attachment;
|
||||||
loadingProof.value = true;
|
loadingProof.value = true;
|
||||||
metaStripped.value = true;
|
metaStripped.value = true;
|
||||||
|
|
@ -41,8 +50,11 @@ onMounted(() => {
|
||||||
if (data && data.data) {
|
if (data && data.data) {
|
||||||
return proofmode.proofCheckSource(data.data).then((res) => {
|
return proofmode.proofCheckSource(data.data).then((res) => {
|
||||||
a.proof = res;
|
a.proof = res;
|
||||||
a.proofHintFlags = extractProofHintFlags(a.proof);
|
if (res?.integrity?.c2pa) {
|
||||||
metaStripped.value = a?.proof?.integrity?.c2pa === undefined && a?.proof?.integrity?.exif === undefined;
|
// If we have proof, overwrite the flags
|
||||||
|
a.mediaMetadata = extractMediaMetadata(a.proof);
|
||||||
|
}
|
||||||
|
updateMetaStripped(a);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -51,17 +63,9 @@ onMounted(() => {
|
||||||
loadingProof.value = false;
|
loadingProof.value = false;
|
||||||
});
|
});
|
||||||
} else {
|
} 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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,17 @@
|
||||||
<v-icon @click.stop="$emit('close')" color="white" class="clickable">arrow_back</v-icon>
|
<v-icon @click.stop="$emit('close')" color="white" class="clickable">arrow_back</v-icon>
|
||||||
<div class="room-name no-upper">{{ displayDate }}</div>
|
<div class="room-name no-upper">{{ displayDate }}</div>
|
||||||
<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
|
>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>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gallery-current-item">
|
<div class="gallery-current-item">
|
||||||
<ThumbnailView :item="currentAttachment" />
|
<ThumbnailView :item="currentAttachment" showInlinePDF />
|
||||||
<div class="download-button clickable" @click.stop="downloadOne">
|
|
||||||
<v-icon color="black">arrow_downward</v-icon>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="gallery-thumbnail-container">
|
<div class="gallery-thumbnail-container">
|
||||||
<div
|
<div
|
||||||
|
|
@ -43,7 +41,7 @@
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-title class="d-flex"> </v-card-title>
|
<v-card-title class="d-flex"> </v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<EventAttachmentInfo :attachment="currentAttachment" />
|
<EventAttachmentInfo :attachment="currentAttachment" v-on:download="downloadOne" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-bottom-sheet>
|
</v-bottom-sheet>
|
||||||
|
|
@ -86,13 +84,18 @@ const displayDate = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const showInfoButton = computed(() => {
|
const showInfoButton = computed(() => {
|
||||||
return currentAttachment.value?.proofHintFlags !== undefined;
|
return currentAttachment.value?.mediaMetadata !== undefined || currentAttachment.value?.mediaInterventionFlags !== undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const moreMenuItems = computed(() => {
|
const moreMenuItems = computed(() => {
|
||||||
let items = [];
|
let items = [];
|
||||||
items.push({
|
items.push({
|
||||||
icon: "$vuetify.icons.ic_download",
|
text: t("message.download_media"),
|
||||||
|
handler: () => {
|
||||||
|
downloadOne();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
text: t("message.download_all"),
|
text: t("message.download_all"),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
downloadAll();
|
downloadAll();
|
||||||
|
|
@ -132,21 +135,11 @@ const downloadAll = () => {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// .file-drop-current-item {
|
.header-button {
|
||||||
// position: relative;
|
width: 44px;
|
||||||
// }
|
height: 44px;
|
||||||
|
padding: 10px;
|
||||||
.download-button {
|
margin-left: 8px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fill-screen {
|
.fill-screen {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,39 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-bind="{ ...$attrs }" class="send-attachments">
|
<div v-bind="{ ...$attrs }" class="send-attachments">
|
||||||
<v-btn
|
<v-btn v-if="!fileModeMode" class="back-button clickable" icon="arrow_back" size="default" elevation="0"
|
||||||
v-if="!fileModeMode"
|
@click.stop="close" :disabled="backButtonDisabled" variant="flat"></v-btn>
|
||||||
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 -->
|
<!-- ATTACHMENT SELECTION MODE -->
|
||||||
<template v-if="status == mainStatuses.SELECTING">
|
<template v-else-if="status == mainStatuses.SELECTING">
|
||||||
<div
|
<div :class="{ 'send-attachments__selecting__current-item': true, 'drop-target': dropTarget }"
|
||||||
:class="{ 'send-attachments__selecting__current-item': true, 'drop-target': dropTarget }"
|
@drop.prevent="filesDropped" @dragover.prevent="dropTarget = true" @dragleave.prevent="dropTarget = false"
|
||||||
@drop.prevent="filesDropped"
|
@dragenter.prevent="dropTarget = true">
|
||||||
@dragover.prevent="dropTarget = true"
|
<ThumbnailView :item="currentAttachment">
|
||||||
@dragleave.prevent="dropTarget = false"
|
<template v-slot:decorator v-if="currentAttachment && currentAttachment.status === 'loading'">
|
||||||
@dragenter.prevent="dropTarget = true"
|
|
||||||
>
|
|
||||||
<ThumbnailView :item="currentAttachment" />
|
|
||||||
<div v-if="currentAttachment && currentAttachment.status === 'loading'" class="send-attachments__selecting__current-item__preparing">
|
|
||||||
<div style="font-size: 0.7em; opacity: 0.7">
|
<div style="font-size: 0.7em; opacity: 0.7">
|
||||||
<v-progress-circular indeterminate class="mb-0"></v-progress-circular>
|
<v-progress-circular indeterminate class="mb-0"></v-progress-circular>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div v-else-if="showCCIcon" class="send-attachments__selecting__current-item__cc">
|
<template v-slot:decorator v-else-if="showCCIcon">
|
||||||
<v-icon style="width:24px;height:24px">$vuetify.icons.ic_cr</v-icon>
|
<v-icon style="width:24px;height:24px">$vuetify.icons.ic_cr</v-icon>
|
||||||
</div>
|
</template>
|
||||||
|
</ThumbnailView>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="file-drop-thumbnail-container">
|
<div class="file-drop-thumbnail-container">
|
||||||
|
|
@ -38,20 +41,14 @@
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-badge :model-value="batch.isTooLarge(attachment)" color="error">
|
<v-badge :model-value="batch.isTooLarge(attachment)" color="error">
|
||||||
<template v-slot:badge><span v-bind="props"> </span></template>
|
<template v-slot:badge><span v-bind="props"> </span></template>
|
||||||
<div
|
<div :class="{ 'file-drop-thumbnail': true, clickable: true, current: index == currentItemIndex }" @click="
|
||||||
:class="{ 'file-drop-thumbnail': true, clickable: true, current: index == currentItemIndex }"
|
|
||||||
@click="
|
|
||||||
() => {
|
() => {
|
||||||
currentItemIndex = index;
|
currentItemIndex = index;
|
||||||
}
|
}
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<v-img v-if="attachment && attachment.src" :src="attachment.src" />
|
<v-img v-if="attachment && attachment.src" :src="attachment.src" />
|
||||||
<div
|
<div v-if="currentItemIndex == index" class="remove clickable"
|
||||||
v-if="currentItemIndex == index"
|
@click.stop="batch.removeAttachment(attachment)">
|
||||||
class="remove clickable"
|
|
||||||
@click.stop="batch.removeAttachment(attachment)"
|
|
||||||
>
|
|
||||||
<v-icon>$vuetify.icons.ic_trash</v-icon>
|
<v-icon>$vuetify.icons.ic_trash</v-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,40 +62,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="file-drop-input-container">
|
<div class="file-drop-input-container">
|
||||||
<div class="file-drop-input-container__input">
|
<div class="file-drop-input-container__input">
|
||||||
<v-text-field
|
<v-text-field ref="input" full-width variant="solo" flat v-model="messageInput" no-resize
|
||||||
ref="input"
|
class="input-area-text" rows="1" :placeholder="$t('file_mode.add_a_message')" hide-details color="white"
|
||||||
full-width
|
background-color="transparent" />
|
||||||
variant="solo"
|
<v-btn class="send-button clickable" icon="send" size="default" elevation="0" color="black"
|
||||||
flat
|
@click.stop="sendAll" :disabled="sendButtonDisabled"></v-btn>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<v-badge location="top right" color="#ff3300" dot class="cc-badge" :model-value="anyContainsCC">
|
<v-badge location="top right" color="#ff3300" dot class="cc-badge" :model-value="showRedDotBadge">
|
||||||
<v-btn
|
<v-btn class="info-button clickable" icon="$vuetify.icons.ic_share_settings" size="44" elevation="0"
|
||||||
class="info-button clickable"
|
color="black" @click.stop="showInformation" :disabled="currentAttachment?.status !== 'loaded'"></v-btn>
|
||||||
icon="$vuetify.icons.ic_share_settings"
|
|
||||||
size="44"
|
|
||||||
elevation="0"
|
|
||||||
color="black"
|
|
||||||
@click.stop="showInformation"
|
|
||||||
:disabled="currentAttachment?.status !== 'loaded'"
|
|
||||||
></v-btn>
|
|
||||||
</v-badge>
|
</v-badge>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -110,13 +82,8 @@
|
||||||
<div class="file-drop-stack-item direct" :style="stackItemTransform(null, -1)"></div>
|
<div class="file-drop-stack-item direct" :style="stackItemTransform(null, -1)"></div>
|
||||||
<div>{{ $t("file_mode.sending_progress") }}</div>
|
<div>{{ $t("file_mode.sending_progress") }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else v-for="(info, index) in batch.attachmentsSent" :key="info.file.name"
|
||||||
v-else
|
class="file-drop-stack-item animated" :style="stackItemTransform(info, index)">
|
||||||
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" />
|
<v-img v-if="info.src" :src="info.src" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="status == mainStatuses.SENT" class="items-sent" :style="stackItemTransform(null, -1)">
|
<div v-if="status == mainStatuses.SENT" class="items-sent" :style="stackItemTransform(null, -1)">
|
||||||
|
|
@ -145,43 +112,21 @@
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="file-drop-section">
|
<div class="file-drop-section">
|
||||||
<v-textarea
|
<v-textarea disabled full-width variant="solo" flat v-model="messageInput" no-resize class="input-area-text"
|
||||||
disabled
|
rows="1" hide-details background-color="transparent" />
|
||||||
full-width
|
|
||||||
variant="solo"
|
|
||||||
flat
|
|
||||||
v-model="messageInput"
|
|
||||||
no-resize
|
|
||||||
class="input-area-text"
|
|
||||||
rows="1"
|
|
||||||
hide-details
|
|
||||||
background-color="transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom section -->
|
<!-- Bottom section -->
|
||||||
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-input-container">
|
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-input-container">
|
||||||
<v-textarea
|
<v-textarea disabled full-width variant="solo" flat auto-grow v-model="messageInput" no-resize
|
||||||
disabled
|
class="input-area-text" rows="1" :placeholder="$t('file_mode.add_a_message')" hide-details
|
||||||
full-width
|
background-color="transparent" />
|
||||||
variant="solo"
|
<v-btn>{{ $t("file_mode.sending")
|
||||||
flat
|
}}<v-progress-circular indeterminate size="18" width="2" color="#4642F1"></v-progress-circular></v-btn>
|
||||||
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>
|
||||||
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sent-input-container">
|
<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>
|
<v-btn class="close" @click.stop="close">{{ $t("file_mode.close") }}</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -189,23 +134,17 @@
|
||||||
<v-bottom-sheet v-model="showAttachmentInformation" theme="dark" height="80%">
|
<v-bottom-sheet v-model="showAttachmentInformation" theme="dark" height="80%">
|
||||||
<v-card class="text-center send-attachments-info-popup">
|
<v-card class="text-center send-attachments-info-popup">
|
||||||
<v-card-title class="d-flex flex-column pa-0">
|
<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-divider />
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-title class="d-flex">
|
<v-card-title class="d-flex">
|
||||||
<v-btn
|
<v-btn class="left-right-arrow flex-0-0" icon="chevron_left" @click.stop="currentItemIndex -= 1"
|
||||||
class="left-right-arrow flex-0-0"
|
:disabled="currentItemIndex == 0"></v-btn>
|
||||||
icon="chevron_left"
|
|
||||||
@click.stop="currentItemIndex -= 1"
|
|
||||||
:disabled="currentItemIndex == 0"
|
|
||||||
></v-btn>
|
|
||||||
<div class="title flex-fill">{{ currentAttachment.file.name }}</div>
|
<div class="title flex-fill">{{ currentAttachment.file.name }}</div>
|
||||||
<v-btn
|
<v-btn class="left-right-arrow flex-0-0" icon="chevron_right" @click.stop="currentItemIndex += 1"
|
||||||
class="left-right-arrow flex-0-0"
|
:disabled="currentItemIndex >= batch.attachments.length - 1"></v-btn>
|
||||||
icon="chevron_right"
|
|
||||||
@click.stop="currentItemIndex += 1"
|
|
||||||
:disabled="currentItemIndex >= batch.attachments.length - 1"
|
|
||||||
></v-btn>
|
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<AttachmentInfo :attachment="currentAttachment" />
|
<AttachmentInfo :attachment="currentAttachment" />
|
||||||
|
|
@ -289,11 +228,17 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
anyContainsCC(): boolean {
|
showRedDotBadge(): boolean {
|
||||||
return this.batch.attachments.some((a: Attachment) => a.proof?.integrity?.c2pa !== undefined);
|
return this.currentAttachment && this.currentAttachment.proof?.integrity?.c2pa !== undefined && !this.currentAttachment.detailsViewed;
|
||||||
},
|
},
|
||||||
showCCIcon(): boolean {
|
showCCIcon(): boolean {
|
||||||
return this.currentAttachment && this.currentAttachment.proof?.integrity?.c2pa !== undefined && !this.currentAttachment.useScaled;
|
return this.currentAttachment && this.currentAttachment.proof?.integrity?.c2pa !== undefined && !this.currentAttachment.useScaled;
|
||||||
|
},
|
||||||
|
currentlyViewedItem(): Attachment | undefined {
|
||||||
|
if (this.showAttachmentInformation) {
|
||||||
|
return this.currentAttachment;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -308,6 +253,11 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
deep: 1,
|
deep: 1,
|
||||||
},
|
},
|
||||||
|
currentlyViewedItem(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
newVal.detailsViewed = true; // We have seen this now
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showInformation() {
|
showInformation() {
|
||||||
|
|
@ -326,6 +276,13 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
this.batch.addFiles(files);
|
this.batch.addFiles(files);
|
||||||
},
|
},
|
||||||
|
reset() {
|
||||||
|
this.sendingAttachments = [];
|
||||||
|
this.status = this.mainStatuses.SELECTING;
|
||||||
|
this.messageInput = "";
|
||||||
|
this.currentItemIndex = 0;
|
||||||
|
this.$emit('reset');
|
||||||
|
},
|
||||||
close() {
|
close() {
|
||||||
this.batch.cancel();
|
this.batch.cancel();
|
||||||
this.status = this.mainStatuses.SELECTING;
|
this.status = this.mainStatuses.SELECTING;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
<video :src="source" :poster="poster" :controls="!previewOnly" class="w-100 h-100">
|
<video :src="source" :poster="poster" :controls="!previewOnly" class="w-100 h-100">
|
||||||
{{ $t("fallbacks.video_file") }}
|
{{ $t("fallbacks.video_file") }}
|
||||||
</video>
|
</video>
|
||||||
|
<div class="video-icon" v-if="previewOnly">
|
||||||
|
<v-icon class="icon" size="34">$vuetify.icons.ic_video</v-icon>
|
||||||
|
</div>
|
||||||
</v-responsive>
|
</v-responsive>
|
||||||
<ImageWithProgress
|
<ImageWithProgress
|
||||||
v-else-if="isImage"
|
v-else-if="isImage"
|
||||||
|
|
@ -13,22 +16,47 @@
|
||||||
:contain="!previewOnly"
|
:contain="!previewOnly"
|
||||||
:cover="previewOnly"
|
:cover="previewOnly"
|
||||||
:loadingProgress="loadingProgress"
|
: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>
|
<v-icon :class="fileTypeIconClass">{{ fileTypeIcon }}</v-icon>
|
||||||
<div class="file-name">{{ $$sanitize(fileName) }}</div>
|
<div class="file-name">{{ $$sanitize(fileName) }}</div>
|
||||||
<div class="file-size">{{ fileSize }}</div>
|
<div class="file-size">{{ fileSize }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :style="decoratorStyle" id="asdi">
|
||||||
|
<slot name="decorator"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { singleOrDoubleTapRecognizer } from "../../plugins/touch";
|
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 { EventAttachment } from "../../models/eventAttachment";
|
||||||
import { useThumbnail } from "../messages/composition/useThumbnail";
|
import { useThumbnail } from "../messages/composition/useThumbnail";
|
||||||
import { Attachment } from "../../models/attachment";
|
import { Attachment } from "../../models/attachment";
|
||||||
import ImageWithProgress from "../ImageWithProgress.vue";
|
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 {
|
function isEventAttachment(source: EventAttachment | Attachment | undefined): source is EventAttachment {
|
||||||
return (source as EventAttachment)?.event !== undefined;
|
return (source as EventAttachment)?.event !== undefined;
|
||||||
|
|
@ -41,10 +69,12 @@ function isAttachment(source: EventAttachment | Attachment | undefined): source
|
||||||
const $$sanitize: any = inject("globalSanitize");
|
const $$sanitize: any = inject("globalSanitize");
|
||||||
|
|
||||||
const thumbnailRef = useTemplateRef("thumbnailRef");
|
const thumbnailRef = useTemplateRef("thumbnailRef");
|
||||||
|
const imageRef = useTemplateRef("imageRef");
|
||||||
|
|
||||||
interface ThumbnailProps {
|
interface ThumbnailProps {
|
||||||
item?: EventAttachment | Attachment;
|
item?: EventAttachment | Attachment;
|
||||||
previewOnly?: boolean;
|
previewOnly?: boolean;
|
||||||
|
showInlinePDF?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThumbnailEmits = {
|
type ThumbnailEmits = {
|
||||||
|
|
@ -52,14 +82,15 @@ type ThumbnailEmits = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<ThumbnailProps>();
|
const props = defineProps<ThumbnailProps>();
|
||||||
const { item, previewOnly = false } = props;
|
const { item, previewOnly = false, showInlinePDF = false } = props;
|
||||||
const emits = defineEmits<ThumbnailEmits>();
|
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 fileURL: Ref<string | undefined> = ref(undefined);
|
||||||
const source: Ref<string | undefined> = ref(undefined);
|
const source: Ref<string | undefined> = ref(undefined);
|
||||||
const poster: Ref<string | undefined> = ref(undefined);
|
const poster: Ref<string | undefined> = ref(undefined);
|
||||||
|
const decoratorStyle: Ref<string | undefined> = ref(undefined);
|
||||||
|
|
||||||
const updateSource = () => {
|
const updateSource = () => {
|
||||||
if (isEventAttachment(props.item)) {
|
if (isEventAttachment(props.item)) {
|
||||||
|
|
@ -112,11 +143,39 @@ const updatePoster = () => {
|
||||||
updateSource();
|
updateSource();
|
||||||
updatePoster();
|
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) => {
|
watch(props, (props: ThumbnailProps) => {
|
||||||
const updates = useThumbnail(isEventAttachment(props.item) ? props.item.event : isAttachment(props.item) ? props.item.file : undefined);
|
const updates = useThumbnail(isEventAttachment(props.item) ? props.item.event : isAttachment(props.item) ? props.item.file : undefined);
|
||||||
isVideo.value = updates.isVideo.value;
|
isVideo.value = updates.isVideo.value;
|
||||||
isImage.value = updates.isImage.value;
|
isImage.value = updates.isImage.value;
|
||||||
|
isPDF.value = updates.isPDF.value;
|
||||||
fileTypeIcon = updates.fileTypeIcon;
|
fileTypeIcon = updates.fileTypeIcon;
|
||||||
fileTypeIconClass = updates.fileTypeIconClass;
|
fileTypeIconClass = updates.fileTypeIconClass;
|
||||||
fileName = updates.fileName;
|
fileName = updates.fileName;
|
||||||
|
|
@ -125,6 +184,53 @@ watch(props, (props: ThumbnailProps) => {
|
||||||
updatePoster();
|
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(() => {
|
const loadingProgress = computed(() => {
|
||||||
if (isEventAttachment(item)) {
|
if (isEventAttachment(item)) {
|
||||||
const eventAttachment = item;
|
const eventAttachment = item;
|
||||||
|
|
@ -152,6 +258,7 @@ onBeforeUnmount(() => {
|
||||||
URL.revokeObjectURL(fileURL.value);
|
URL.revokeObjectURL(fileURL.value);
|
||||||
fileURL.value = undefined;
|
fileURL.value = undefined;
|
||||||
}
|
}
|
||||||
|
pageIntersectionObserver?.disconnect()
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -163,6 +270,17 @@ onBeforeUnmount(() => {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
|
&.pdf-file {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
|
|
@ -177,4 +295,19 @@ onBeforeUnmount(() => {
|
||||||
color: currentColor;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,38 @@ export default {
|
||||||
return {
|
return {
|
||||||
languages: [],
|
languages: [],
|
||||||
activeLang:null,
|
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: {
|
methods: {
|
||||||
|
|
@ -19,8 +50,8 @@ export default {
|
||||||
const context = this
|
const context = this
|
||||||
for (const locale of Object.keys(this.$i18n.messages)) {
|
for (const locale of Object.keys(this.$i18n.messages)) {
|
||||||
this.languages.push({
|
this.languages.push({
|
||||||
title: this.$i18n.messages[locale].language_display_name || locale,
|
title: this.languageDisplayName[locale],
|
||||||
text: this.$i18n.messages[locale].language_display_name || locale,
|
text: this.languageDisplayName[locale],
|
||||||
value: locale,
|
value: locale,
|
||||||
display: context.displayLanguage.includes(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-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-icon size="small">get_app</v-icon>
|
||||||
</v-btn>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,7 @@
|
||||||
<span>{{ $t("message.seen_by_count", seenBy.length) }}</span>
|
<span>{{ $t("message.seen_by_count", seenBy.length) }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<BottomSheet
|
<BottomSheet ref="seenByListBottomSheet">
|
||||||
:halfY="0.12"
|
|
||||||
ref="seenByListBottomSheet"
|
|
||||||
>
|
|
||||||
<v-list>
|
<v-list>
|
||||||
<v-list-subheader class="text-uppercase"> {{ $t("message.seen_by") }}</v-list-subheader>
|
<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">
|
<v-list-item v-for="(member, index) in seenBy" :key="index" class="text-left">
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<component :is="rootComponent" v-bind="{ ...$props, ...$attrs }">
|
<component :is="rootComponent" v-bind="{ ...$props, ...$attrs }">
|
||||||
<div class="bubble">
|
<div class="bubble">
|
||||||
{{ inOut }}
|
|
||||||
<div class="original-message" v-if="inReplyToText">
|
<div class="original-message" v-if="inReplyToText">
|
||||||
<div class="original-message-sender">{{ inReplyToSender }}</div>
|
<div class="original-message-sender">{{ inReplyToSender }}</div>
|
||||||
<div class="original-message-text" v-html="linkify($$sanitize(inReplyToText))" />
|
<div class="original-message-text" v-html="linkify($$sanitize(inReplyToText))" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message">
|
<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>
|
<span class="edit-marker" v-if="event?.replacingEventId()">{{ $t("message.edited") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -16,7 +15,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, ref, Ref } from "vue";
|
import { computed, inject } from "vue";
|
||||||
import MessageIncoming from "./MessageIncoming.vue";
|
import MessageIncoming from "./MessageIncoming.vue";
|
||||||
import MessageOutgoing from "./MessageOutgoing.vue";
|
import MessageOutgoing from "./MessageOutgoing.vue";
|
||||||
import ThumbnailView from "../../file_mode/ThumbnailView.vue";
|
import ThumbnailView from "../../file_mode/ThumbnailView.vue";
|
||||||
|
|
@ -28,8 +27,6 @@ const { t } = useI18n();
|
||||||
const $matrix: any = inject("globalMatrix");
|
const $matrix: any = inject("globalMatrix");
|
||||||
const $$sanitize: any = inject("globalSanitize");
|
const $$sanitize: any = inject("globalSanitize");
|
||||||
|
|
||||||
const inOut: Ref<"in" | "out"> = ref("in");
|
|
||||||
|
|
||||||
const emits = defineEmits<{ (event: "download", value: KeanuEvent | undefined): void }>();
|
const emits = defineEmits<{ (event: "download", value: KeanuEvent | undefined): void }>();
|
||||||
const props = defineProps<MessageProps>();
|
const props = defineProps<MessageProps>();
|
||||||
|
|
||||||
|
|
@ -44,10 +41,6 @@ const { event, isIncoming, attachment, inReplyToText, inReplyToSender, linkify }
|
||||||
const rootComponent = computed(() => {
|
const rootComponent = computed(() => {
|
||||||
return isIncoming.value ? MessageIncoming : MessageOutgoing;
|
return isIncoming.value ? MessageIncoming : MessageOutgoing;
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDownload = () => {
|
|
||||||
emits("download", event.value);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@
|
||||||
v-bind="{ ...$props, ...$attrs }"
|
v-bind="{ ...$props, ...$attrs }"
|
||||||
>
|
>
|
||||||
<div class="bubble image-bubble" ref="imageRef">
|
<div class="bubble image-bubble" ref="imageRef">
|
||||||
<div class="bubble-inset" v-if="attachment?.proofHintFlags">
|
<MediaIntervention :multiple="false" :media-intervention-flags="attachment?.mediaInterventionFlags ? [attachment.mediaInterventionFlags] : []" />
|
||||||
<CCSummary :multiple="false" :flags="attachment.proofHintFlags ? [attachment.proofHintFlags] : []" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImageWithProgress v-if="attachment"
|
<ImageWithProgress v-if="attachment"
|
||||||
:aspect-ratio="16 / 9"
|
:aspect-ratio="16 / 9"
|
||||||
|
|
@ -36,7 +34,7 @@ import { MessageProps, useMessage } from "./useMessage";
|
||||||
import { EventAttachment } from "../../../models/eventAttachment";
|
import { EventAttachment } from "../../../models/eventAttachment";
|
||||||
import { useDisplay } from "vuetify";
|
import { useDisplay } from "vuetify";
|
||||||
import Hammer from "hammerjs";
|
import Hammer from "hammerjs";
|
||||||
import CCSummary from "../../content-credentials/CCSummary.vue";
|
import MediaIntervention from "../../content-credentials/MediaIntervention.vue";
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const $matrix: any = inject('globalMatrix');
|
const $matrix: any = inject('globalMatrix');
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<component :is="rootComponent" ref="root" v-bind="{ ...$props, ...$attrs }" v-if="showMultiview">
|
<component :is="rootComponent" ref="root" v-bind="{ ...$props, ...$attrs }" v-if="showMultiview">
|
||||||
<div class="bubble">
|
<div class="bubble">
|
||||||
<div class="bubble-inset" v-if="showCCSummary">
|
<MediaIntervention :multiple="items.length > 1" :mediaInterventionFlags="mediaInterventionFlags" />
|
||||||
<CCSummary :multiple="items.length > 1" :flags="proofHintFlags" />
|
|
||||||
</div>
|
|
||||||
<div class="original-message bubble-inset" v-if="inReplyToText">
|
<div class="original-message bubble-inset" v-if="inReplyToText">
|
||||||
<div class="original-message-sender">{{ inReplyToSender }}</div>
|
<div class="original-message-sender">{{ inReplyToSender }}</div>
|
||||||
<div class="original-message-text" v-html="linkify($$sanitize(inReplyToText))" />
|
<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 GalleryItemsView from "../../file_mode/GalleryItemsView.vue";
|
||||||
import ThumbnailView from "../../file_mode/ThumbnailView.vue";
|
import ThumbnailView from "../../file_mode/ThumbnailView.vue";
|
||||||
import SwipeableThumbnailsView from "../channel/SwipeableThumbnailsView.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 { computed, inject, onBeforeUnmount, ref, Ref, useTemplateRef, watch } from "vue";
|
||||||
import { EventAttachment } from "../../../models/eventAttachment";
|
import { EventAttachment } from "../../../models/eventAttachment";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk";
|
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk";
|
||||||
import { useLazyLoad } from "./useLazyLoad";
|
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 { t } = useI18n();
|
||||||
const $matrix: any = inject("globalMatrix");
|
const $matrix: any = inject("globalMatrix");
|
||||||
|
|
@ -163,6 +161,8 @@ const showMessageText = computed((): boolean => {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isSinglePDF = computed(() => items.value.length === 1 && util.isFileTypePDF(items.value[0].event))
|
||||||
|
|
||||||
const showMultiview = computed((): boolean => {
|
const showMultiview = computed((): boolean => {
|
||||||
if (["m.image", "m.video"].includes(event.value?.getContent().msgtype ?? "")) {
|
if (["m.image", "m.video"].includes(event.value?.getContent().msgtype ?? "")) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -171,21 +171,15 @@ const showMultiview = computed((): boolean => {
|
||||||
(isIncoming.value && props.room.displayType == ROOM_TYPE_FILE_MODE) ||
|
(isIncoming.value && props.room.displayType == ROOM_TYPE_FILE_MODE) ||
|
||||||
items.value?.length > 1 ||
|
items.value?.length > 1 ||
|
||||||
(event.value && event.value.isRedacted()) ||
|
(event.value && event.value.isRedacted()) ||
|
||||||
(props.room.displayType == ROOM_TYPE_CHANNEL &&
|
(props.room.displayType == ROOM_TYPE_CHANNEL && isSinglePDF.value) ||
|
||||||
items.value.length == 1 &&
|
messageText.value?.length > 0 || isSinglePDF.value
|
||||||
util.isFileTypePDF(items.value[0].event)) ||
|
|
||||||
messageText.value?.length > 0
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const showCCSummary = computed(() => {
|
const mediaInterventionFlags = computed(() => {
|
||||||
return items.value?.some((i) => i.proofHintFlags !== undefined);
|
return items.value.reduce((res: MediaInterventionFlags[], item) => {
|
||||||
});
|
if (item.mediaInterventionFlags) {
|
||||||
|
res.push(item.mediaInterventionFlags);
|
||||||
const proofHintFlags = computed(() => {
|
|
||||||
return items.value.reduce((res: ProofHintFlags[], item) => {
|
|
||||||
if (item.proofHintFlags) {
|
|
||||||
res.push(item.proofHintFlags);
|
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export const useThumbnail = (source: KeanuEvent | File | undefined) => {
|
||||||
} else if (source) {
|
} else if (source) {
|
||||||
const file = source as File;
|
const file = source as File;
|
||||||
isVideo.value = file.type.startsWith("video/");
|
isVideo.value = file.type.startsWith("video/");
|
||||||
isImage.value = file.type.startsWith("image/");
|
isImage.value = utils.isSupportedImageType(file.type);
|
||||||
fileName.value = file.name;
|
fileName.value = file.name;
|
||||||
fileSize.value = prettyBytes(file.size);
|
fileSize.value = prettyBytes(file.size);
|
||||||
}
|
}
|
||||||
|
|
@ -70,5 +70,6 @@ export const useThumbnail = (source: KeanuEvent | File | undefined) => {
|
||||||
fileTypeIconClass,
|
fileTypeIconClass,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize,
|
fileSize,
|
||||||
|
isPDF
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -58,5 +58,9 @@ export default {
|
||||||
this.$emit("close");
|
this.$emit("close");
|
||||||
this.$emit("unpin", {event:this.event});
|
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 vuetify from './plugins/vuetify';
|
||||||
import { Buffer } from 'buffer/'
|
import { Buffer } from 'buffer/'
|
||||||
import { createApp, h } from 'vue';
|
import { createApp, h } from 'vue';
|
||||||
|
import { supportedImageTypes, supportedAvatarImageTypes } from '@/plugins/utils';
|
||||||
|
|
||||||
globalThis.Buffer = Buffer;
|
globalThis.Buffer = Buffer;
|
||||||
|
|
||||||
var defaultOptions = Vue3Sanitize.defaults;
|
var defaultOptions = Vue3Sanitize.defaults;
|
||||||
|
|
@ -25,6 +27,9 @@ defaultOptions.allowedTags = [];
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
render: () => h(App)
|
render: () => h(App)
|
||||||
});
|
});
|
||||||
|
app.config.globalProperties.supportedImageTypes = supportedImageTypes;
|
||||||
|
app.config.globalProperties.supportedAvatarImageTypes = supportedAvatarImageTypes;
|
||||||
|
|
||||||
app.use(Vue3Sanitize, defaultOptions);
|
app.use(Vue3Sanitize, defaultOptions);
|
||||||
|
|
||||||
app.config.productionTip = false
|
app.config.productionTip = false
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ComputedRef, Ref } from "vue";
|
import { ComputedRef, Ref } from "vue";
|
||||||
import { Proof, ProofHintFlags } from "./proof";
|
import { Proof, MediaMetadata } from "./proof";
|
||||||
|
|
||||||
export class UploadPromise<Type> {
|
export class UploadPromise<Type> {
|
||||||
wrappedPromise: Promise<Type>;
|
wrappedPromise: Promise<Type>;
|
||||||
|
|
@ -53,13 +53,14 @@ export type Attachment = {
|
||||||
status: "loading" | "loaded";
|
status: "loading" | "loaded";
|
||||||
file: File;
|
file: File;
|
||||||
dimensions?: { width: number; height: number };
|
dimensions?: { width: number; height: number };
|
||||||
scaledFile?: File;
|
compressedFile?: File;
|
||||||
scaledDimensions?: { width: number; height: number };
|
compressedDimensions?: { width: number; height: number };
|
||||||
useScaled: boolean;
|
useCompressed: boolean;
|
||||||
src?: string;
|
src?: string;
|
||||||
proof?: Proof;
|
proof?: Proof;
|
||||||
proofHintFlags?: ProofHintFlags;
|
mediaMetadata?: MediaMetadata;
|
||||||
thumbnail?: AttachmentThumbnail;
|
thumbnail?: AttachmentThumbnail;
|
||||||
|
detailsViewed: boolean;
|
||||||
sendInfo: AttachmentSendInfo;
|
sendInfo: AttachmentSendInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
EventAttachmentUrlType,
|
EventAttachmentUrlType,
|
||||||
KeanuEvent,
|
KeanuEvent,
|
||||||
KeanuEventExtension,
|
KeanuEventExtension,
|
||||||
|
KeanuRoom,
|
||||||
} from "./eventAttachment";
|
} from "./eventAttachment";
|
||||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
import { Counter, ModeOfOperation } from "aes-js";
|
import { Counter, ModeOfOperation } from "aes-js";
|
||||||
|
|
@ -18,8 +19,8 @@ import {
|
||||||
import proofmode from "../plugins/proofmode";
|
import proofmode from "../plugins/proofmode";
|
||||||
import imageResize from "image-resize";
|
import imageResize from "image-resize";
|
||||||
import { computed, ref, Ref, shallowReactive, unref } from "vue";
|
import { computed, ref, Ref, shallowReactive, unref } from "vue";
|
||||||
import utils, { THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT, CLIENT_EVENT_PROOF_HINT } from "@/plugins/utils";
|
import utils, { THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT, CLIENT_EVENT_MEDIA_INTERVENTION_FLAGS } from "@/plugins/utils";
|
||||||
import { extractProofHintFlags } from "./proof";
|
import { extractMediaMetadata } from "./proof";
|
||||||
|
|
||||||
export class AttachmentManager {
|
export class AttachmentManager {
|
||||||
matrixClient: MatrixClient;
|
matrixClient: MatrixClient;
|
||||||
|
|
@ -48,15 +49,16 @@ export class AttachmentManager {
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public createUpload(room: Room) {
|
public createUpload(room: KeanuRoom) {
|
||||||
return createUploadBatch(this, room);
|
return createUploadBatch(this, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
public createAttachment(file: File, room: Room): Attachment {
|
public createAttachment(file: File, room: KeanuRoom): Attachment {
|
||||||
let a: Attachment = {
|
let a: Attachment = {
|
||||||
status: "loading",
|
status: "loading",
|
||||||
file: file,
|
file: file,
|
||||||
useScaled: false,
|
useCompressed: false,
|
||||||
|
detailsViewed: false,
|
||||||
sendInfo: {
|
sendInfo: {
|
||||||
status: "initial",
|
status: "initial",
|
||||||
statusDate: 0,
|
statusDate: 0,
|
||||||
|
|
@ -73,9 +75,9 @@ export class AttachmentManager {
|
||||||
return ra;
|
return ra;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async prepareUpload(attachment: Attachment, room: Room): Promise<Attachment> {
|
private async prepareUpload(attachment: Attachment, room: KeanuRoom): Promise<Attachment> {
|
||||||
const file = attachment.file;
|
const file = attachment.file;
|
||||||
if (file.type.startsWith("image/")) {
|
if (utils.isSupportedImageType(file.type)) {
|
||||||
let url = URL.createObjectURL(file);
|
let url = URL.createObjectURL(file);
|
||||||
attachment.src = url;
|
attachment.src = url;
|
||||||
if (attachment.src) {
|
if (attachment.src) {
|
||||||
|
|
@ -104,11 +106,11 @@ export class AttachmentManager {
|
||||||
height: newHeight,
|
height: newHeight,
|
||||||
outputType: "blob",
|
outputType: "blob",
|
||||||
});
|
});
|
||||||
attachment.scaledFile = new File([compressedImg as BlobPart], file.name, {
|
attachment.compressedFile = new File([compressedImg as BlobPart], file.name, {
|
||||||
type: "image/webp",
|
type: "image/webp",
|
||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
});
|
});
|
||||||
attachment.scaledDimensions = {
|
attachment.compressedDimensions = {
|
||||||
width: newWidth,
|
width: newWidth,
|
||||||
height: newHeight,
|
height: newHeight,
|
||||||
};
|
};
|
||||||
|
|
@ -118,24 +120,33 @@ export class AttachmentManager {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
attachment.proof = await proofmode.proofCheckFile(file);
|
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
|
// Default to scaled version if the image does not contain Content Credentials
|
||||||
//
|
//
|
||||||
const isDirectRoom = (room: Room) => {
|
const isDirectRoom = (room: Room) => {
|
||||||
// TODO - Use the is_direct accountData flag (m.direct). WE (as the client)
|
// TODO - Use the is_direct accountData flag (m.direct). WE (as the client)
|
||||||
// apprently need to set this...
|
// 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 true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
attachment.useScaled =
|
const isChannel = room.displayType == "im.keanu.room_type_channel";
|
||||||
attachment.scaledFile !== undefined &&
|
const isFileDrop = room.displayType == "im.keanu.room_type_file";
|
||||||
(attachment.proof === undefined ||
|
|
||||||
!isDirectRoom(room) ||
|
let useOriginal = false;
|
||||||
attachment.proof.integrity === undefined ||
|
if (isChannel) {
|
||||||
attachment.proof.integrity.c2pa === undefined);
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to get content credentials: " + error);
|
console.error("Failed to get content credentials: " + error);
|
||||||
}
|
}
|
||||||
|
|
@ -220,7 +231,7 @@ export class AttachmentManager {
|
||||||
|
|
||||||
const fileSize = this.getSrcFileSize(event);
|
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 = {
|
const attachment: EventAttachment = {
|
||||||
event: event,
|
event: event,
|
||||||
|
|
@ -229,7 +240,8 @@ export class AttachmentManager {
|
||||||
srcProgress: -1,
|
srcProgress: -1,
|
||||||
thumbnailProgress: -1,
|
thumbnailProgress: -1,
|
||||||
autoDownloadable: fileSize <= this.maxSizeAutoDownloads,
|
autoDownloadable: fileSize <= this.maxSizeAutoDownloads,
|
||||||
proofHintFlags: proofHintFlags ? JSON.parse(proofHintFlags) : proofHintFlags,
|
mediaInterventionFlags: mediaInterventionFlags ? JSON.parse(mediaInterventionFlags) : undefined,
|
||||||
|
mediaMetadata: undefined,
|
||||||
proof: undefined,
|
proof: undefined,
|
||||||
loadSrc: () => Promise.reject("Not implemented"),
|
loadSrc: () => Promise.reject("Not implemented"),
|
||||||
loadThumbnail: () => 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 matrixClient = manager?.matrixClient;
|
||||||
const maxSizeUploads = manager?.maxSizeUploads ?? 0;
|
const maxSizeUploads = manager?.maxSizeUploads ?? 0;
|
||||||
|
|
||||||
|
|
@ -489,7 +501,7 @@ export const createUploadBatch = (manager: AttachmentManager | null, room: Room
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTooLarge = (attachment: Attachment) => {
|
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;
|
return file.size > maxSizeUploads;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -607,11 +619,11 @@ export const createUploadBatch = (manager: AttachmentManager | null, room: Room
|
||||||
item.sendInfo.status = "sending";
|
item.sendInfo.status = "sending";
|
||||||
|
|
||||||
let file = (() => {
|
let file = (() => {
|
||||||
if (attachment.scaledFile && attachment.useScaled) {
|
if (attachment.compressedFile && attachment.useCompressed) {
|
||||||
// Send scaled version of image instead!
|
// Send compressed!
|
||||||
return attachment.scaledFile;
|
return attachment.compressedFile;
|
||||||
} else {
|
} else {
|
||||||
// Send actual file image when not scaled!
|
// Send original
|
||||||
return attachment.file;
|
return attachment.file;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
@ -632,7 +644,7 @@ export const createUploadBatch = (manager: AttachmentManager | null, room: Room
|
||||||
eventId,
|
eventId,
|
||||||
attachment.dimensions,
|
attachment.dimensions,
|
||||||
attachment.thumbnail,
|
attachment.thumbnail,
|
||||||
attachment.proofHintFlags
|
attachment.mediaMetadata
|
||||||
)
|
)
|
||||||
.then((mediaEventId: string) => {
|
.then((mediaEventId: string) => {
|
||||||
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
|
// 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 { MatrixEvent, Room } from "matrix-js-sdk";
|
||||||
import { AttachmentBatch } from "./attachment";
|
import { AttachmentBatch } from "./attachment";
|
||||||
import { Proof, ProofHintFlags } from "./proof";
|
import { Proof, MediaMetadata, MediaInterventionFlags } from "./proof";
|
||||||
|
|
||||||
export type KeanuEventExtension = {
|
export type KeanuEventExtension = {
|
||||||
isMxThread?: boolean;
|
isMxThread?: boolean;
|
||||||
|
|
@ -28,7 +28,8 @@ export type EventAttachment = {
|
||||||
thumbnailPromise?: Promise<EventAttachmentUrlData>;
|
thumbnailPromise?: Promise<EventAttachmentUrlData>;
|
||||||
autoDownloadable: boolean;
|
autoDownloadable: boolean;
|
||||||
proof?: Proof;
|
proof?: Proof;
|
||||||
proofHintFlags?: ProofHintFlags;
|
mediaInterventionFlags?: MediaInterventionFlags;
|
||||||
|
mediaMetadata?: MediaMetadata;
|
||||||
loadSrc: () => Promise<EventAttachmentUrlData>;
|
loadSrc: () => Promise<EventAttachmentUrlData>;
|
||||||
loadThumbnail: () => Promise<EventAttachmentUrlData>;
|
loadThumbnail: () => Promise<EventAttachmentUrlData>;
|
||||||
loadBlob: () => Promise<{data: Blob}>;
|
loadBlob: () => Promise<{data: Blob}>;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import utils from "@/plugins/utils";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export type AIInferenceResult = {
|
export type AIInferenceResult = {
|
||||||
aiGenerated: boolean;
|
aiGenerated: boolean;
|
||||||
aiProbability: number;
|
aiProbability: number;
|
||||||
|
|
@ -59,21 +62,38 @@ export type Proof = {
|
||||||
ai?: { inferenceResult?: AIInferenceResult };
|
ai?: { inferenceResult?: AIInferenceResult };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProofHintFlagsGenerator = "unknown" | "camera" | "screenshot" | "ai";
|
export type MediaMetadataGenerator = "unknown" | "camera" | "screenshot" | "ai";
|
||||||
export type ProofHintFlagsGeneratorSource = "c2pa" | "exif" | "metadata";
|
export type MediaMetadataPropertySource = "c2pa" | "exif" | "metadata";
|
||||||
export type ProofHintFlagsEditor = "unknown" | "manual" | "ai";
|
|
||||||
|
|
||||||
export type ProofHintFlagsEdit = {
|
export type MediaMetadataEdit = {
|
||||||
editor: ProofHintFlagsEditor;
|
editor: string;
|
||||||
date?: Date;
|
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;
|
device?: string;
|
||||||
creationDate?: Date;
|
creationDate?: Date;
|
||||||
generator?: ProofHintFlagsGenerator;
|
creationDateSource?: MediaMetadataPropertySource;
|
||||||
generatorSource?: ProofHintFlagsGeneratorSource;
|
generator?: MediaMetadataGenerator;
|
||||||
edits?: ProofHintFlagsEdit[];
|
generatorSource?: MediaMetadataPropertySource;
|
||||||
|
edits?: MediaMetadataEdit[];
|
||||||
|
containsC2PA?: boolean;
|
||||||
|
containsEXIF?: boolean;
|
||||||
|
location?: MediaMetadataLocation;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FlagMatchRule = {
|
type FlagMatchRule = {
|
||||||
|
|
@ -82,9 +102,14 @@ type FlagMatchRule = {
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FlagMatchRuleValue = {
|
type FlagMatchRulePathSegment = {
|
||||||
|
object: any;
|
||||||
path: string;
|
path: string;
|
||||||
value: string;
|
};
|
||||||
|
|
||||||
|
type FlagMatchRuleValue = {
|
||||||
|
path: FlagMatchRulePathSegment[];
|
||||||
|
value: string | object;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FlagMatchInfo = {
|
type FlagMatchInfo = {
|
||||||
|
|
@ -93,51 +118,118 @@ type FlagMatchInfo = {
|
||||||
re: string;
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
field:
|
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=stds.exif]/data/exif:Make",
|
||||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/digitalSourceType",
|
transform: getExifMakeModelPrefixed,
|
||||||
match: [C2PASourceTypeScreenCapture],
|
},
|
||||||
description: "Screen capture",
|
{
|
||||||
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
field:
|
path: "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions|c2pa.actions.v2]/data/actions[action=c2pa.created]/when",
|
||||||
"name",
|
},
|
||||||
match: ["screenshot"],
|
{
|
||||||
description: "Screen capture",
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
field:
|
path: "integrity/exif/UserComment",
|
||||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/digitalSourceType",
|
transform: exifStringTransform,
|
||||||
match: [C2PASourceTypeDigitalCapture, C2PASourceTypeComputationalCapture, C2PASourceTypeCompositeCapture],
|
matches: ["^Screenshot$"],
|
||||||
description: "Captured by camera",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ruleAiGenerated = (): FlagMatchRule[] => {
|
const pathsMetaScreenshot = (): FlagValue[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
field:
|
path: "name",
|
||||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/digitalSourceType",
|
matches: ["screenshot"],
|
||||||
match: [C2PASourceTypeTrainedAlgorithmicMedia, C2PASourceTypeCompositeWithTrainedAlgorithmicMedia],
|
}
|
||||||
description: "Generated by AI",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = [
|
const knownAIServices = [
|
||||||
"ChatGPT",
|
"ChatGPT",
|
||||||
"OpenAI-API",
|
"OpenAI-API",
|
||||||
|
|
@ -145,79 +237,171 @@ const ruleAiMeta = (): FlagMatchRule[] => {
|
||||||
"RunwayML",
|
"RunwayML",
|
||||||
"Runway AI",
|
"Runway AI",
|
||||||
"Google AI",
|
"Google AI",
|
||||||
"Stable Diffusion",
|
"Stable Diffusion"
|
||||||
];
|
];
|
||||||
return [
|
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",
|
path: "integrity/c2pa/manifest_info/manifests[]/claim_generator",
|
||||||
match: knownAIServices,
|
matches: knownAIServices,
|
||||||
description: "C2PA claim generator",
|
description: "C2PA claim generator",
|
||||||
},
|
},
|
||||||
{ field: "iptc/Credit", match: knownAIServices, description: "IPTC Credit" },
|
{ path: "iptc/Credit", matches: knownAIServices, description: "IPTC Credit" },
|
||||||
{ field: "iptc/Provider", match: knownAIServices, description: "IPTC Provider" },
|
{ path: "iptc/Provider", matches: knownAIServices, description: "IPTC Provider" },
|
||||||
{ field: "iptc/ImageSupplier[]", match: knownAIServices, description: "IPTC ImageSupplier" },
|
{ path: "iptc/ImageSupplier[]", matches: knownAIServices, description: "IPTC ImageSupplier" },
|
||||||
{ field: "iptc/ImageCreator[]", match: knownAIServices, description: "IPTC ImageCreator" },
|
{ path: "iptc/ImageCreator[]", matches: knownAIServices, description: "IPTC ImageCreator" },
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchFlag = (rules: FlagMatchRule[], file: any) => {
|
const getExifValue = (val: string): string => {
|
||||||
let result = false;
|
return val.replace(/^"(.+(?="$))"$/, "$1");
|
||||||
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 extractFlagValues = (flagPath: string, file: any): FlagMatchRuleValue[] => {
|
const toDegrees = (dms: string | undefined, direction: string) => {
|
||||||
const getValues = (
|
if (!dms || dms.length == 0) return undefined;
|
||||||
path: string[],
|
var parts = dms.split(/deg|min|sec/);
|
||||||
objectPath: any[],
|
var d = parts[0];
|
||||||
actualPath: string,
|
var m = parts[1];
|
||||||
o: any
|
var s = parts[2];
|
||||||
): FlagMatchRuleValue[] | undefined => {
|
var deg = (Number(d) + Number(m) / 60 + Number(s) / 3600).toFixed(6);
|
||||||
if (path.length == 0 || o == undefined) return undefined;
|
if (direction == "S" || direction == "W") {
|
||||||
let part = path[0];
|
deg = "-" + deg;
|
||||||
const lastBracket = part.lastIndexOf("[");
|
|
||||||
if (part === "..") {
|
|
||||||
return getValues(path.slice(1), objectPath.slice(1), actualPath + "/..", objectPath[0]);
|
|
||||||
}
|
}
|
||||||
if (part.endsWith("]") && lastBracket > 0) {
|
return deg;
|
||||||
const optionalConstraint = part.substring(lastBracket + 1, part.length - 1);
|
};
|
||||||
part = part.substring(0, lastBracket);
|
|
||||||
if (o[part] != undefined) {
|
const pathsExifCreationDate = (): FlagValue[] => {
|
||||||
let opart: any[] = o[part];
|
return [
|
||||||
if (!Array.isArray(opart)) {
|
{
|
||||||
opart = Object.values(opart) ?? [];
|
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?
|
// Any constraints controlling what array object(s) to consider?
|
||||||
if (optionalConstraint) {
|
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("=");
|
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) {
|
if (omatches.length > 0) {
|
||||||
let strings = opart as string[];
|
if (keys.length == 1) {
|
||||||
return strings.map((s, i) => ({ path: actualPath + "/" + part + "[" + i + "]", value: s }));
|
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 {
|
} else {
|
||||||
return opart.reduce((res: FlagMatchRuleValue[] | undefined, o: any, i: number) => {
|
return omatches.reduce((res: FlagMatchRuleValue[] | undefined, oin: any, i: number) => {
|
||||||
const newObjectPaths = [o, ...objectPath];
|
let matches = getValues(keys.slice(1), [{ object: oin, path: key + "[" + i + "]" }, ...path]);
|
||||||
let matches = getValues(path.slice(1), newObjectPaths, actualPath + "/" + part + "[" + i + "]", o);
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
const r2 = res || [];
|
const r2 = res || [];
|
||||||
r2.push(...matches);
|
r2.push(...matches);
|
||||||
|
|
@ -229,34 +413,88 @@ const extractFlagValues = (flagPath: string, file: any): FlagMatchRuleValue[] =>
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
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[] = [];
|
let result: FlagMatchRuleValue[] = [];
|
||||||
try {
|
try {
|
||||||
let parts = flagPath.split("/");
|
let keys = flagPath.split("/");
|
||||||
result = getValues(parts, [], "", file) ?? [];
|
result = getValues(keys, path) ?? [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Invalid RE", e);
|
console.error("Invalid RE", e);
|
||||||
}
|
}
|
||||||
return result;
|
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;
|
if (!proof) return undefined;
|
||||||
|
|
||||||
let edits: ProofHintFlagsEdit[] | undefined = undefined;
|
let edits: MediaMetadataEdit[] | undefined = undefined;
|
||||||
let valid = false;
|
let valid = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -265,49 +503,127 @@ export const extractProofHintFlags = (proof?: Proof): ProofHintFlags | undefined
|
||||||
valid = results.failure.length == 0 && results.success.length > 0;
|
valid = results.failure.length == 0 && results.success.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = extractFlagValues(
|
const rootMatchPath = [{ object: proof, path: "" }];
|
||||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/softwareAgent",
|
|
||||||
proof
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateCreated = extractFlagValues(
|
let source: string | undefined = undefined;
|
||||||
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/../../../../signature_info/time",
|
let dateCreated: Date | undefined = undefined;
|
||||||
proof
|
let dateCreatedSource: MediaMetadataPropertySource | undefined = undefined;
|
||||||
);
|
|
||||||
let date: Date | undefined = undefined;
|
source = getFirstWithData(pathsC2PASource(), rootMatchPath);
|
||||||
if (dateCreated && dateCreated.length == 1) {
|
if (!source) {
|
||||||
try {
|
source = getFirstWithData(pathsExifSource(), rootMatchPath);
|
||||||
date = new Date(Date.parse(dateCreated[0].value));
|
|
||||||
} catch (error) {}
|
|
||||||
}
|
}
|
||||||
console.log("DATE CREATED", date);
|
|
||||||
|
|
||||||
let generator: ProofHintFlagsGenerator = matchFlag(ruleAiGenerated(), proof).result ? "ai" : matchFlag(ruleScreenshotC2PA(), proof).result ? "screenshot" : matchFlag(ruleCamera(), proof).result ? "camera" : "unknown";
|
dateCreated = getFirstWithDataAsDate(pathsC2PACreationDate(), rootMatchPath);
|
||||||
let generatorSource: ProofHintFlagsGeneratorSource | undefined = undefined;
|
if (dateCreated) {
|
||||||
|
dateCreatedSource = "c2pa";
|
||||||
if (generator !== "unknown" && valid) {
|
|
||||||
generatorSource = "c2pa";
|
|
||||||
} else {
|
} 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";
|
generator = "screenshot";
|
||||||
generatorSource = "metadata";
|
generatorSource = "metadata";
|
||||||
} else if (matchFlag(ruleAiMeta(), proof).result) {
|
} else if (getFirstWithData(pathsMetaAI(), rootMatchPath)) {
|
||||||
generator = "ai";
|
generator = "ai";
|
||||||
generatorSource = "metadata";
|
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.
|
// 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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flags: ProofHintFlags = {
|
const flags: MediaMetadata = {
|
||||||
device: source && source.length == 1 ? source[0].value : undefined,
|
device: source,
|
||||||
creationDate: date,
|
creationDate: dateCreated,
|
||||||
|
creationDateSource: dateCreatedSource,
|
||||||
generator: generator,
|
generator: generator,
|
||||||
generatorSource: generatorSource,
|
generatorSource: generatorSource,
|
||||||
edits: edits,
|
edits: edits,
|
||||||
|
containsC2PA: proof.integrity?.c2pa !== undefined,
|
||||||
|
containsEXIF: proof.integrity?.exif !== undefined,
|
||||||
|
location: location,
|
||||||
};
|
};
|
||||||
return flags;
|
return flags;
|
||||||
} catch (error) {}
|
} 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 i18n from "./lang";
|
||||||
import { toRaw, isRef, isReactive, isProxy } from "vue";
|
import { toRaw, isRef, isReactive, isProxy } from "vue";
|
||||||
import { UploadPromise } from "../models/attachment";
|
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_DELETION_NOTICE = "im.keanu.room_deletion_notice";
|
||||||
export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted";
|
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 ROOM_TYPE_CHANNEL = "im.keanu.room_type_channel";
|
||||||
|
|
||||||
export const STATE_EVENT_ROOM_TYPE = "im.keanu.room_type";
|
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_WIDTH = 640;
|
||||||
export const THUMBNAIL_MAX_HEIGHT = 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
|
// Install extended localized format
|
||||||
dayjs.extend(localizedFormat);
|
dayjs.extend(localizedFormat);
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
@ -396,7 +417,7 @@ class Util {
|
||||||
return [encryptedBytes, encryptedFile];
|
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);
|
const uploadPromise = new UploadPromise(undefined);
|
||||||
uploadPromise.wrappedPromise = new Promise((resolve, reject) => {
|
uploadPromise.wrappedPromise = new Promise((resolve, reject) => {
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
|
|
@ -435,7 +456,7 @@ class Util {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.type.startsWith("image/")) {
|
if (this.isSupportedImageType(file.type)) {
|
||||||
msgtype = "m.image";
|
msgtype = "m.image";
|
||||||
|
|
||||||
// Generate thumbnail?
|
// Generate thumbnail?
|
||||||
|
|
@ -477,9 +498,10 @@ class Util {
|
||||||
msgtype: msgtype,
|
msgtype: msgtype,
|
||||||
};
|
};
|
||||||
|
|
||||||
// if (proofHintFlags) {
|
if (mediaMetadata) {
|
||||||
// messageContent[CLIENT_EVENT_PROOF_HINT] = JSON.stringify(proofHintFlags);
|
const interventionFlags = mediaMetadataToMediaInterventionFlags(mediaMetadata);
|
||||||
// }
|
messageContent[CLIENT_EVENT_MEDIA_INTERVENTION_FLAGS] = JSON.stringify(interventionFlags);
|
||||||
|
}
|
||||||
|
|
||||||
// If thread root (an eventId) is set, add that here
|
// If thread root (an eventId) is set, add that here
|
||||||
if (threadRoot) {
|
if (threadRoot) {
|
||||||
|
|
@ -831,7 +853,24 @@ class Util {
|
||||||
return images;
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
axios
|
axios
|
||||||
.get(file, { responseType: "arraybuffer" })
|
.get(file, { responseType: "arraybuffer" })
|
||||||
|
|
@ -842,9 +881,16 @@ class Util {
|
||||||
progressHandler: onUploadProgress,
|
progressHandler: onUploadProgress,
|
||||||
onlyContentUri: false,
|
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;
|
var avatarUri;
|
||||||
matrix.matrixClient
|
matrix.matrixClient
|
||||||
.uploadContent(response.data, opts)
|
.uploadContent(data, opts)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
avatarUri = response.content_uri;
|
avatarUri = response.content_uri;
|
||||||
return matrix.matrixClient.setAvatarUrl(avatarUri);
|
return matrix.matrixClient.setAvatarUrl(avatarUri);
|
||||||
|
|
@ -913,7 +959,7 @@ class Util {
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
if (file.type.startsWith("image/")) {
|
if (this.isSupportedImageType(file.type)) {
|
||||||
try {
|
try {
|
||||||
var image = e.target.result;
|
var image = e.target.result;
|
||||||
|
|
||||||
|
|
@ -1015,6 +1061,24 @@ class Util {
|
||||||
return then.format("lll");
|
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() {
|
browserCanRecordAudio() {
|
||||||
return _browserCanRecordAudio;
|
return _browserCanRecordAudio;
|
||||||
}
|
}
|
||||||
|
|
@ -1191,6 +1255,10 @@ class Util {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSupportedImageType(mime) {
|
||||||
|
return supportedImageTypes.some(prefix => mime.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
isMobileOrTabletBrowser() {
|
isMobileOrTabletBrowser() {
|
||||||
// Regular expression to match common mobile and tablet browser user agent strings
|
// 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;
|
const mobileTabletPattern = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Tablet|Mobile|CriOS/i;
|
||||||
|
|
|
||||||
|
|
@ -876,11 +876,7 @@ export default {
|
||||||
const withRetry = (codeBlock) => {
|
const withRetry = (codeBlock) => {
|
||||||
return codeBlock().catch((error) => {
|
return codeBlock().catch((error) => {
|
||||||
if (error && error.errcode == "M_LIMIT_EXCEEDED") {
|
if (error && error.errcode == "M_LIMIT_EXCEEDED") {
|
||||||
var retry = 1000;
|
const retry = error.getRetryAfterMs() ?? 1000;
|
||||||
if (error.data) {
|
|
||||||
const retryIn = error.data.retry_after_ms;
|
|
||||||
retry = Math.max(retry, retryIn ? retryIn : 0);
|
|
||||||
}
|
|
||||||
console.log("Rate limited, retry in", retry);
|
console.log("Rate limited, retry in", retry);
|
||||||
return sleep(retry).then(() => withRetry(codeBlock));
|
return sleep(retry).then(() => withRetry(codeBlock));
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue