More work on sending/reading proof hint flags

This commit is contained in:
N-Pex 2025-09-05 11:16:50 +02:00
parent dd76692640
commit 66eef037e0
9 changed files with 267 additions and 219 deletions

View file

@ -482,7 +482,8 @@ $hiliteColor: #4642f1;
} }
.send-attachments-info-popup { .send-attachments-info-popup {
background-color: rgba(0, 0, 0, 0.9); background-color: rgba(0, 0, 0, 0.8);
border-radius: 24px 24px 0 0;
.done-button { .done-button {
padding: 14px 24px; padding: 14px 24px;
@ -621,6 +622,20 @@ $hiliteColor: #4642f1;
} }
} }
.detail-info {
color: #333333;
background-color: #DAD9FC;
padding: 16px;
margin: 16px 0 16px 0;
border-radius: 8px 8px 0 8px;
font-family: "Inter", sans-serif;
font-weight: 400;
font-size: 14px;
line-height: 125%;
letter-spacing: 0.4px;
vertical-align: middle;
}
.detail-row { .detail-row {
margin-top: 12px; margin-top: 12px;
font-family: "Inter", sans-serif; font-family: "Inter", sans-serif;
@ -629,9 +644,29 @@ $hiliteColor: #4642f1;
line-height: 125%; line-height: 125%;
letter-spacing: 0.4px; letter-spacing: 0.4px;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
display: flex;
.v-icon { .v-icon {
padding: 9.33px;
margin-right: 8px; margin-right: 8px;
width: 32px;
height: 32px;
background-color: black;
}
.detail-row__text {
display: flex;
flex-direction: column;
}
.detail-row__title {
color: #dad9fc;
font-family: "Inter", sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 125%;
letter-spacing: 0.4px;
vertical-align: middle;
} }
} }
} }

View file

@ -504,8 +504,16 @@
"content_credentials_info": "Source or history information is available for this media to be verified.", "content_credentials_info": "Source or history information is available for this media to be verified.",
"learn_more": "Learn more", "learn_more": "Learn more",
"ai_used": "Photo modified with AI", "ai_used": "Photo modified with AI",
"screenshot": "Screenshot. ",
"screenshot_taken_on": "Screenshot taken on {date}", "screenshot_taken_on": "Screenshot taken on {date}",
"captured_with_camera": "Captured with a camera", "captured_with_camera": "Captured with a real camera. ",
"old_photo": "Photo older than 3 months" "captured_screenshot": "Screenshot. ",
"captured_screenshot_ago": "Screenshot captured {ago} ago. ",
"generated_with_ai": "Generated with AI. ",
"generated_with_ai_ago": "Generated with AI {ago} ago. ",
"old_photo": "Photo older than 3 months. ",
"cc_source": "Source",
"cc_capture_timestamp": "Capture Timestamp",
"cc_location": "Location"
} }
} }

View file

@ -1,132 +1,99 @@
<template> <template>
<div v-if="c2pa"> <div v-if="props.flags">
<div class="detail-title"> <div class="detail-title">
{{ t("file_mode.content_credentials") }} {{ t("file_mode.content_credentials") }}
<v-icon>$vuetify.icons.ic_cr</v-icon> <v-icon>$vuetify.icons.ic_cr</v-icon>
</div> </div>
<div class="detail-subtitle"> <div class="detail-subtitle">
{{ t("file_mode.content_credentials_info") }} {{ t("file_mode.content_credentials_info") }}
<!-- <a href="" target="_blank">{{ t("file_mode.learn_more") }}</a> -->
</div>
<div class="detail-row" v-if="screenCapture">
<v-icon>$vuetify.icons.ic_media_screenshot</v-icon>{{ screenCapture }}
</div>
<template v-else>
<div class="detail-row" v-if="dateCreated">
<v-icon>$vuetify.icons.ic_exif_time</v-icon>{{ dateCreatedDisplay }}
</div>
<div class="detail-row" v-if="creator"><v-icon>$vuetify.icons.ic_media_device</v-icon>{{ creator }}</div>
<div class="detail-row" v-if="valid && cameraCapture">
<v-icon>$vuetify.icons.ic_media_camera</v-icon>{{ cameraCapture }}
</div>
</template>
<div class="detail-row" v-if="ai || aiInferenceResult?.aiGenerated">
<v-icon>$vuetify.icons.ic_cc_ai</v-icon>{{ t("file_mode.ai_used") }}
</div>
<div class="detail-row" v-if="showAgeWarning">
<v-icon>$vuetify.icons.ic_media_flag</v-icon>{{ t("file_mode.old_photo") }}
</div> </div>
<!-- {{ JSON.stringify(props.c2pa, undefined, 4) }} --> <div class="detail-info" v-if="infoText !== undefined">
{{ infoText }}
</div>
<CCProperty
v-if="props.flags?.source"
icon="$vuetify.icons.ic_media_camera"
:title="t('file_mode.cc_source')"
:value="props.flags?.source"
/>
<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 { import {
AIInferenceResult, ProofHintFlags,
C2PAActionsAssertion,
C2PAData,
C2PASourceTypeCompositeCapture,
C2PASourceTypeCompositeWithTrainedAlgorithmicMedia,
C2PASourceTypeComputationalCapture,
C2PASourceTypeDigitalCapture,
C2PASourceTypeScreenCapture,
C2PASourceTypeTrainedAlgorithmicMedia,
} from "../../models/proof"; } from "../../models/proof";
import { computed, ref, Ref, watch } from "vue"; import { ref, Ref, watch } from "vue";
import dayjs, { Dayjs } from "dayjs"; import dayjs from "dayjs";
import CCProperty from "./CCProperty.vue";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps<{ const props = defineProps<{
c2pa?: C2PAData; flags?: ProofHintFlags;
aiInferenceResult?: AIInferenceResult;
}>(); }>();
//console.error("C2PA", JSON.stringify(props.c2pa, undefined, 4)); const infoText: Ref<string | undefined> = ref(undefined);
const creationDate: Ref<string | undefined> = ref(undefined);
const creator: Ref<string | undefined> = ref(undefined);
const dateCreated: Ref<Dayjs | undefined> = ref(undefined);
const screenCapture: Ref<string | undefined> = ref(undefined);
const cameraCapture: Ref<string | undefined> = ref(undefined);
const ai: Ref<boolean> = ref(false);
const valid: Ref<boolean> = ref(false); const valid: Ref<boolean> = ref(false);
watch( watch(
props, props,
() => { () => {
creator.value = undefined; infoText.value = undefined;
dateCreated.value = undefined; creationDate.value = undefined;
screenCapture.value = undefined;
cameraCapture.value = undefined;
ai.value = false;
valid.value = false; valid.value = false;
try { try {
const manifests = Object.values(props.c2pa?.manifest_info.manifests ?? {}); if (props.flags) {
for (const manifest of manifests) { let date = props.flags.creationDate ? dayjs(props.flags.creationDate) : undefined;
for (const assertion of manifest.assertions) { if (date) {
if (assertion.label === "c2pa.actions") { creationDate.value = date.format("lll");
const actions = (assertion.data as C2PAActionsAssertion)?.actions ?? [];
const a = actions.find((a) => a.action === "c2pa.created");
if (a) {
creator.value = a.softwareAgent;
dateCreated.value = dayjs(Date.parse(manifest.signature_info.time));
if (a.digitalSourceType === C2PASourceTypeScreenCapture) {
screenCapture.value = t("file_mode.screenshot_taken_on", { date: dateCreated.value });
}
if (
a.digitalSourceType === C2PASourceTypeDigitalCapture ||
a.digitalSourceType === C2PASourceTypeComputationalCapture ||
a.digitalSourceType === C2PASourceTypeCompositeCapture
) {
cameraCapture.value = t("file_mode.captured_with_camera");
}
if (
a.digitalSourceType === C2PASourceTypeTrainedAlgorithmicMedia ||
a.digitalSourceType === C2PASourceTypeCompositeWithTrainedAlgorithmicMedia
) {
ai.value = true;
}
return;
}
}
}
} }
let results = props.c2pa?.manifest_info.validation_results?.activeManifest; let result = "";
if (results) { if (props.flags.camera) {
valid.value = results.failure.length == 0 && results.success.length > 0; result += t("file_mode.captured_with_camera");
} }
if (props.flags.screenshot) {
if (date) {
result += t("file_mode.captured_screenshot_ago", { ago: date.fromNow(true) });
} else {
result += t("file_mode.captured_screenshot");
}
}
if (props.flags.aiGenerated) {
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;
}
valid.value = props.flags?.valid ?? false;
} catch (error) {} } catch (error) {}
}, },
{ immediate: true } { immediate: true }
); );
const dateCreatedDisplay = computed(() => {
if (dateCreated.value) {
return dateCreated.value.format("lll");
}
return undefined;
});
const showAgeWarning = computed(() => {
if (dateCreated.value) {
return dayjs().diff(dateCreated.value, "month") >= 3;
}
return false;
});
</script> </script>
<style lang="scss"> <style lang="scss">

View file

@ -0,0 +1,23 @@
<template>
<div class="detail-row">
<v-icon>{{ props.icon }}</v-icon>
<div class="detail-row__text">
<div class="detail-row__title">{{ props.title }}</div>
<div>
<slot name="default">{{ props.value }}</slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
icon: string;
title: string;
value?: string;
}>();
</script>
<style lang="scss">
@use "@/assets/css/chat.scss" as *;
</style>

View file

@ -3,16 +3,13 @@
<div class="detail-title"> <div class="detail-title">
{{ t("file_mode.exif_data") }} {{ t("file_mode.exif_data") }}
</div> </div>
<div class="detail-row" v-if="dateTime"> <CCProperty v-if="makeAndModel" icon="$vuetify.icons.ic_exif_device_camera" :title="t('file_mode.cc_source')" :value="makeAndModel" />
<v-icon>$vuetify.icons.ic_exif_time</v-icon>{{ dateTime }} <CCProperty v-if="dateTime" icon="$vuetify.icons.ic_exif_time" :title="t('file_mode.cc_capture_timestamp')" :value="dateTime" />
</div> <CCProperty v-if="location" icon="$vuetify.icons.ic_exif_location" :title="t('file_mode.cc_location')">
<div class="detail-row" v-if="location"> <template v-slot:default>
<v-icon>$vuetify.icons.ic_exif_location</v-icon><a :href="locationLink">{{ location }}</a> <a :href="locationLink">{{ location }}</a>
</div> </template>
<div class="detail-row" v-if="makeAndModel"> </CCProperty>
<v-icon>$vuetify.icons.ic_exif_device_camera</v-icon>{{ makeAndModel }}
</div>
<!-- Exif {{ JSON.stringify(props.exif, undefined, 4) }} -->
</div> </div>
</template> </template>
@ -20,6 +17,7 @@
import { computed } from "vue"; import { computed } from "vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import CCProperty from "./CCProperty.vue";
const { t } = useI18n(); const { t } = useI18n();

View file

@ -28,7 +28,7 @@
</div> </div>
</div> </div>
<C2PAInfo class="attachment-info__detail-box" v-if="hasC2PA" :c2pa="attachment.proof?.integrity?.c2pa" :ai-inference-result="attachment.proof?.ai?.inferenceResult" /> <C2PAInfo class="attachment-info__detail-box" v-if="hasC2PA" :flags="attachment.proofHintFlags" />
<EXIFInfo class="attachment-info__detail-box" v-if="hasExif" :exif="attachment.proof?.integrity?.exif" /> <EXIFInfo class="attachment-info__detail-box" v-if="hasExif" :exif="attachment.proof?.integrity?.exif" />
</div> </div>
</template> </template>

View file

@ -1,11 +1,13 @@
<template> <template>
<div class="fill-screen send-attachments"> <div class="fill-screen send-attachments">
<div class="chat-header"> <div class="chat-header">
<v-container fluid class="d-flex justify-space-between align-center"> <v-container fluid class="d-flex justify-space-between align-center">
<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>
<v-icon v-if="showInfoButton" color="white" class="clickable">info_outline</v-icon>
<v-icon @click.stop="showMoreMenu = true" color="white" class="clickable">more_vert</v-icon> <v-icon @click.stop="showMoreMenu = true" color="white" class="clickable">more_vert</v-icon>
</div>
</v-container> </v-container>
</div> </div>
@ -16,9 +18,16 @@
</div> </div>
</div> </div>
<div class="gallery-thumbnail-container"> <div class="gallery-thumbnail-container">
<div :class="{ 'file-drop-thumbnail': true, 'clickable': true, 'current': id == currentItemIndex }" <div
@click="currentItemIndex = id" v-for="(currentImageInput, id) in items" :key="id"> :class="{ 'file-drop-thumbnail': true, clickable: true, current: id == currentItemIndex }"
<v-img v-if="currentImageInput" :src="currentImageInput.thumbnail ? currentImageInput.thumbnail : currentImageInput.src" /> @click="currentItemIndex = id"
v-for="(currentImageInput, id) in items"
:key="id"
>
<v-img
v-if="currentImageInput"
:src="currentImageInput.thumbnail ? currentImageInput.thumbnail : currentImageInput.src"
/>
</div> </div>
</div> </div>
@ -27,82 +36,79 @@
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import MoreMenuPopup from "../MoreMenuPopup"; import { useI18n } from "vue-i18n";
import messageMixin from "../messages/messageMixin"; import MoreMenuPopup from "../MoreMenuPopup.vue";
import util from "../../plugins/utils"; import util, { CLIENT_EVENT_PROOF_HINT } from "../../plugins/utils";
import ThumbnailView from './ThumbnailView.vue'; import ThumbnailView from "./ThumbnailView.vue";
import { EventAttachment, KeanuEvent } from "../../models/eventAttachment";
import { computed, inject, onBeforeUnmount, onMounted, Ref, ref, watch } from "vue";
export default { const { t } = useI18n();
mixins: [messageMixin], const $matrix: any = inject("globalMatrix");
components: { MoreMenuPopup, ThumbnailView },
props: { const props = defineProps<{ originalEvent: KeanuEvent, items: EventAttachment[]; initialItem: EventAttachment | undefined }>();
items: {
type: Array, const currentItemIndex: Ref<number> = ref(0);
default: function () { const showMoreMenu: Ref<boolean> = ref(false);
return []
}
}, onMounted(() => {
initialItem: {
type: Object,
default: function() {
return null;
}
}
},
data() {
return {
currentItemIndex: 0,
showMoreMenu: false,
};
},
mounted() {
document.body.classList.add("dark"); document.body.classList.add("dark");
if (this.initialItem) { if (props.initialItem) {
this.currentItemIndex = this.items.findIndex((v) => v === this.initialItem); currentItemIndex.value = props.items.findIndex((v) => v === props.initialItem);
if (this.currentItemIndex < 0) { if (currentItemIndex.value < 0) {
this.currentItemIndex = 0; currentItemIndex.value = 0;
} }
} }
}, });
beforeUnmount() {
onBeforeUnmount(() => {
document.body.classList.remove("dark"); document.body.classList.remove("dark");
}, });
computed: {
displayDate() { const displayDate = computed(() => {
return util.formatRecordStartTime(this.originalEvent.getTs()) return util.formatRecordStartTime(props.originalEvent.getTs());
}, });
moreMenuItems() {
const showInfoButton = computed(() => {
const item = currentItemIndex.value >= 0 && currentItemIndex.value < props.items.length ? props.items[currentItemIndex.value] : undefined;
if (item) {
return item.event.getContent()[CLIENT_EVENT_PROOF_HINT] !== undefined;
}
return false;
});
const moreMenuItems = computed(() => {
let items = []; let items = [];
items.push({ items.push({
icon: '$vuetify.icons.ic_download', text: this.$t("message.download_all"), handler: () => { icon: "$vuetify.icons.ic_download",
this.downloadAll(); text: t("message.download_all"),
} handler: () => {
downloadAll();
},
}); });
return items; return items;
}, });
},
watch: { watch(props.items, (newValue: EventAttachment[], oldValue: EventAttachment[]) => {
items(newValue, oldValue) {
// Added or removed? // Added or removed?
if (newValue && oldValue && newValue.length > oldValue.length) { if (newValue && oldValue && newValue.length > oldValue.length) {
this.currentItemIndex = oldValue.length; currentItemIndex.value = oldValue.length;
} else if (newValue) { } else if (newValue) {
this.currentItemIndex = newValue.length - 1; currentItemIndex.value = newValue.length - 1;
}
},
},
methods: {
downloadOne() {
if (this.currentItemIndex >= 0 && this.currentItemIndex < this.items.length) {
util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, this.items[this.currentItemIndex].event);
}
},
downloadAll() {
this.items.forEach(item => util.download(this.$matrix.matrixClient, this.$matrix.useAuthedMedia, item.event));
} }
});
const downloadOne = () => {
if (currentItemIndex.value >= 0 && currentItemIndex.value < props.items.length) {
util.download($matrix.matrixClient, $matrix.useAuthedMedia, props.items[currentItemIndex.value].event);
} }
}; };
const downloadAll = () => {
props.items.forEach((item) => util.download($matrix.matrixClient, $matrix.useAuthedMedia, item.event));
};
</script> </script>
<style lang="scss"> <style lang="scss">
@ -125,7 +131,7 @@ export default {
bottom: 21px; bottom: 21px;
width: 34px; width: 34px;
height: 34px; height: 34px;
background: rgba(255,255,255,0.8); background: rgba(255, 255, 255, 0.8);
border-radius: 17px; border-radius: 17px;
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -38,13 +38,6 @@
</span> </span>
</div> </div>
</div> </div>
<GalleryItemsView
:originalEvent="originalEvent"
:items="items"
:initialItem="showItem"
v-if="!!showItem"
v-on:close="showItem = undefined"
/>
</MessageOutgoing> </MessageOutgoing>
</template> </template>
@ -52,7 +45,6 @@
import MessageOutgoing from "./MessageOutgoing.vue"; import MessageOutgoing from "./MessageOutgoing.vue";
import { MessageProps, useMessage } from "./useMessage"; import { MessageProps, useMessage } from "./useMessage";
import { ROOM_TYPE_CHANNEL } from "@/plugins/utils"; import { ROOM_TYPE_CHANNEL } from "@/plugins/utils";
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 { inject, ref, Ref, unref, watch } from "vue"; import { inject, ref, Ref, unref, watch } from "vue";
@ -65,7 +57,6 @@ const $$sanitize: any = inject("globalSanitize");
let items: Ref<Attachment[]> = ref([]); let items: Ref<Attachment[]> = ref([]);
const layoutedItems: Ref<{ size: number; item: Attachment }[]> = ref([]); const layoutedItems: Ref<{ size: number; item: Attachment }[]> = ref([]);
const showItem: Ref<Attachment | undefined> = ref(undefined);
const uploadBatch: Ref<AttachmentBatch | undefined> = ref(undefined); const uploadBatch: Ref<AttachmentBatch | undefined> = ref(undefined);
@ -93,10 +84,6 @@ const retryUpload = () => {
} }
} }
const onItemClick = (event: any) => {
showItem.value = event.item;
};
const layout = () => { const layout = () => {
if (!items.value || items.value.length == 0) { if (!items.value || items.value.length == 0) {
layoutedItems.value = []; layoutedItems.value = [];

View file

@ -60,6 +60,9 @@ export type Proof = {
}; };
export type ProofHintFlags = { export type ProofHintFlags = {
valid: boolean;
source?: string;
creationDate?: Date;
aiGenerated?: boolean; aiGenerated?: boolean;
aiEdited?: boolean; aiEdited?: boolean;
screenshot?: boolean; screenshot?: boolean;
@ -150,10 +153,11 @@ const matchFlag = (rules: FlagMatchRule[], file: any) => {
values.forEach((v) => { values.forEach((v) => {
re.forEach((r) => { re.forEach((r) => {
if (r.test(v.value)) { if (r.test(v.value)) {
resultInfo.push({ field: v.path, value: v.value, re: r.source }) result = true;
resultInfo.push({ field: v.path, value: v.value, re: r.source });
} }
}); });
}) });
} catch (e) { } catch (e) {
console.error("Invalid RE", e); console.error("Invalid RE", e);
} }
@ -162,7 +166,12 @@ const matchFlag = (rules: FlagMatchRule[], file: any) => {
}; };
const extractFlagValues = (flagPath: string, file: any): FlagMatchRuleValue[] => { const extractFlagValues = (flagPath: string, file: any): FlagMatchRuleValue[] => {
const getValues = (path: string[], objectPath: any[], actualPath: string, o: any): FlagMatchRuleValue[] | undefined => { const getValues = (
path: string[],
objectPath: any[],
actualPath: string,
o: any
): FlagMatchRuleValue[] | undefined => {
if (path.length == 0 || o == undefined) return undefined; if (path.length == 0 || o == undefined) return undefined;
let part = path[0]; let part = path[0];
const lastBracket = part.lastIndexOf("["); const lastBracket = part.lastIndexOf("[");
@ -237,19 +246,34 @@ export const extractProofHintFlags = (proof?: Proof): ProofHintFlags | undefined
if (results) { if (results) {
valid = results.failure.length == 0 && results.success.length > 0; valid = results.failure.length == 0 && results.success.length > 0;
} }
if (valid) {
const dateCreated = extractFlagValues("integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/../../../../signature_info/time", proof); const source = extractFlagValues(
console.log("DATE CREATED", dateCreated); "integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/softwareAgent",
proof
);
const dateCreated = extractFlagValues(
"integrity/c2pa/manifest_info/manifests[]/assertions[label=c2pa.actions]/data/actions[action=c2pa.created]/../../../../signature_info/time",
proof
);
let date: Date | undefined = undefined;
if (dateCreated && dateCreated.length == 1) {
try {
date = new Date(Date.parse(dateCreated[0].value));
} catch (error) {}
}
console.log("DATE CREATED", date);
const flags: ProofHintFlags = { const flags: ProofHintFlags = {
valid: valid,
source: source && source.length == 1 ? source[0].value : undefined,
creationDate: date,
aiGenerated: matchFlag(ruleAiGenerated(), proof).result, aiGenerated: matchFlag(ruleAiGenerated(), proof).result,
aiEdited, aiEdited,
screenshot: matchFlag(ruleScreenshot(), proof).result, screenshot: matchFlag(ruleScreenshot(), proof).result,
camera: matchFlag(ruleCamera(), proof).result, camera: matchFlag(ruleCamera(), proof).result,
}; };
return flags; return flags;
}
} catch (error) {} } catch (error) {}
return undefined; return undefined;
}; };