Merge branch 'improvements' into 'master'

Some various improvements

See merge request framasoft/mobilizon!936
This commit is contained in:
Thomas Citharel 2021-06-10 13:52:28 +00:00
commit f97fe9403c
48 changed files with 1280 additions and 853 deletions

View File

@ -66,6 +66,7 @@ config :mobilizon, Mobilizon.Web.Upload,
uploader: Mobilizon.Web.Upload.Uploader.Local,
filters: [
Mobilizon.Web.Upload.Filter.Dedupe,
Mobilizon.Web.Upload.Filter.AnalyzeMetadata,
Mobilizon.Web.Upload.Filter.Optimize
],
allow_list_mime_types: ["image/gif", "image/jpeg", "image/png", "image/webp"],

View File

@ -30,6 +30,7 @@
"@tiptap/starter-kit": "^2.0.0-beta.37",
"@tiptap/vue-2": "^2.0.0-beta.21",
"apollo-absinthe-upload-link": "^1.5.0",
"blurhash": "^1.1.3",
"buefy": "^0.9.0",
"bulma-divider": "^0.2.0",
"core-js": "^3.6.4",

View File

@ -16,6 +16,7 @@ import { IMember } from "@/types/actor/member.model";
import { IComment } from "@/types/comment.model";
import { IEvent } from "@/types/event.model";
import { IActivity } from "@/types/activity.model";
import uniqBy from "lodash/uniqBy";
type possibleTypes = { name: string };
type schemaType = {
@ -58,7 +59,7 @@ export const typePolicies: TypePolicies = {
Event: {
fields: {
participants: paginatedLimitPagination<IParticipant>(["roles"]),
commnents: pageLimitPagination<IComment>(),
comments: pageLimitPagination<IComment>(),
relatedEvents: pageLimitPagination<IEvent>(),
},
},
@ -124,10 +125,6 @@ export function pageLimitPagination<T = Reference>(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
merge(existing, incoming, { args }) {
console.log("pageLimitPagination");
console.log("existing", existing);
console.log("incoming", incoming);
// console.log("args", args);
if (!incoming) return existing;
if (!existing) return incoming; // existing will be empty the first time
@ -144,9 +141,6 @@ export function paginatedLimitPagination<T = Paginate<any>>(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
merge(existing, incoming, { args }) {
console.log("paginatedLimitPagination");
console.log("existing", existing);
console.log("incoming", incoming);
if (!incoming) return existing;
if (!existing) return incoming; // existing will be empty the first time
@ -168,7 +162,6 @@ function doMerge<T = any>(
if (args) {
// Assume an page of 1 if args.page omitted.
const { page = 1, limit = 10 } = args;
console.log("args, selected", { page, limit });
for (let i = 0; i < incoming.length; ++i) {
merged[(page - 1) * limit + i] = incoming[i];
}
@ -179,7 +172,8 @@ function doMerge<T = any>(
// exception here, instead of recovering by appending incoming
// onto the existing array.
res = [...merged, ...incoming];
// eslint-disable-next-line no-underscore-dangle
res = uniqBy(res, (elem: any) => elem.__ref);
}
console.log("doMerge returns", res);
return res;
}

View File

@ -64,14 +64,11 @@ $color-black: #000;
}
body {
// background: #f7f8fa;
background: $body-background-color;
font-family: BlinkMacSystemFont, Roboto, Oxygen, Ubuntu, Cantarell, "Segoe UI",
"Fira Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
/*main {*/
/* margin: 1rem auto 0;*/
/*}*/
overflow-x: hidden;
}
#mobilizon > .container > .message {

View File

@ -1,10 +1,13 @@
<template>
<li :class="{ reply: comment.inReplyToComment }">
<article
class="media"
:class="{ selected: commentSelected }"
:id="commentId"
<li
:class="{
reply: comment.inReplyToComment,
announcement: comment.isAnnouncement,
selected: commentSelected,
}"
class="comment-element"
>
<article class="media" :id="commentId">
<popover-actor-card
:actor="comment.actor"
:inline="true"
@ -33,14 +36,12 @@
<strong :class="{ organizer: commentFromOrganizer }">{{
comment.actor.name
}}</strong>
<small class="has-text-grey">{{
usernameWithDomain(comment.actor)
}}</small>
<small>{{ usernameWithDomain(comment.actor) }}</small>
</span>
<a v-else class="comment-link has-text-grey" :href="commentURL">
<a v-else class="comment-link" :href="commentURL">
<span>{{ $t("[deleted]") }}</span>
</a>
<a class="comment-link has-text-grey" :href="commentURL">
<a class="comment-link" :href="commentURL">
<small>{{
formatDistanceToNow(new Date(comment.updatedAt), {
locale: $dateFnsLocale,
@ -265,7 +266,7 @@ export default class Comment extends Vue {
}
get commentSelected(): boolean {
return this.commentId === this.$route.hash;
return `#${this.commentId}` === this.$route.hash;
}
get commentFromOrganizer(): boolean {
@ -276,13 +277,13 @@ export default class Comment extends Vue {
get commentId(): string {
if (this.comment.originComment)
return `#comment-${this.comment.originComment.uuid}-${this.comment.uuid}`;
return `#comment-${this.comment.uuid}`;
return `comment-${this.comment.originComment.uuid}-${this.comment.uuid}`;
return `comment-${this.comment.uuid}`;
}
get commentURL(): string {
if (!this.comment.local && this.comment.url) return this.comment.url;
return this.commentId;
return `#${this.commentId}`;
}
reportModal(): void {
@ -368,6 +369,7 @@ form.reply {
a.comment-link {
text-decoration: none;
margin-left: 5px;
color: $text;
&:hover {
text-decoration: underline;
}
@ -378,6 +380,41 @@ a.comment-link {
}
}
.comment-element {
padding: 0.25rem;
border-radius: 5px;
&.announcement {
background: $purple-2;
small {
color: hsl(0, 0%, 21%);
}
}
&.selected {
background-color: $violet-1;
color: $white;
.reply-btn,
small,
strong,
.icons button {
color: $white;
}
a.comment-link:hover {
text-decoration: underline;
text-decoration-color: $white;
small {
color: $purple-3;
}
}
}
.media-left {
margin-right: 0.5rem;
}
}
.root-comment .replies {
display: flex;
@ -402,6 +439,7 @@ a.comment-link {
}
.media .media-content {
overflow-x: initial;
.content .editor-line {
display: flex;
align-items: center;
@ -433,16 +471,12 @@ a.comment-link {
.level-item.reply-btn {
font-weight: bold;
color: $primary;
color: $violet-2;
}
article {
border-radius: 4px;
margin-bottom: 5px;
&.selected {
background-color: lighten($secondary, 30%);
}
}
.comment-replies {

View File

@ -74,7 +74,7 @@
@delete-comment="deleteComment"
/>
</transition-group>
<div class="no-comments" key="no-comments">
<div v-else class="no-comments" key="no-comments">
<span>{{ $t("No comments yet") }}</span>
</div>
</transition-group>
@ -311,7 +311,18 @@ export default class CommentTree extends Vue {
return this.comments
.filter((comment) => comment.inReplyToComment == null)
.sort((a, b) => {
if (a.updatedAt && b.updatedAt) {
if (a.isAnnouncement !== b.isAnnouncement) {
return (
(b.isAnnouncement === true ? 1 : 0) -
(a.isAnnouncement === true ? 1 : 0)
);
}
if (a.publishedAt && b.publishedAt) {
return (
new Date(b.publishedAt).getTime() -
new Date(a.publishedAt).getTime()
);
} else if (a.updatedAt && b.updatedAt) {
return (
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);

View File

@ -12,9 +12,17 @@
</docs>
<template>
<time class="datetime-container" :datetime="dateObj.getUTCSeconds()">
<span class="month">{{ month }}</span>
<time
class="datetime-container"
:class="{ small }"
:datetime="dateObj.getUTCSeconds()"
:style="`--small: ${smallStyle}`"
>
<div class="datetime-container-header" />
<div class="datetime-container-content">
<span class="day">{{ day }}</span>
<span class="month">{{ month }}</span>
</div>
</time>
</template>
<script lang="ts">
@ -26,6 +34,7 @@ export default class DateCalendarIcon extends Vue {
* `date` can be a string or an actual date object.
*/
@Prop({ required: true }) date!: string;
@Prop({ required: false, default: false }) small!: boolean;
get dateObj(): Date {
return new Date(this.$props.date);
@ -38,28 +47,41 @@ export default class DateCalendarIcon extends Vue {
get day(): string {
return this.dateObj.toLocaleString(undefined, { day: "numeric" });
}
get smallStyle(): string {
return this.small ? "1.2" : "2";
}
}
</script>
<style lang="scss" scoped>
time.datetime-container {
background: $backgrounds;
border: 1px solid $borders;
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
/*height: 50px;*/
width: 50px;
padding: 8px;
text-align: center;
overflow-y: hidden;
overflow-x: hidden;
align-items: stretch;
width: calc(40px * var(--small));
box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
height: calc(40px * var(--small));
background: #fff;
.datetime-container-header {
height: calc(10px * var(--small));
background: #f3425f;
}
.datetime-container-content {
height: calc(30px * var(--small));
}
span {
display: block;
font-weight: 600;
color: $violet-3;
&.month {
color: $danger;
padding: 2px 0;
font-size: 12px;
line-height: 12px;
@ -67,9 +89,8 @@ time.datetime-container {
}
&.day {
color: $violet-3;
font-size: 20px;
line-height: 20px;
font-size: calc(1rem * var(--small));
line-height: calc(1rem * var(--small));
}
}
}

View File

@ -0,0 +1,33 @@
<template>
<div class="banner-container">
<lazy-image-wrapper :picture="picture" />
</div>
</template>
<script lang="ts">
import { IMedia } from "@/types/media.model";
import { Component, Prop, Vue } from "vue-property-decorator";
import LazyImageWrapper from "../Image/LazyImageWrapper.vue";
@Component({
components: {
LazyImageWrapper,
},
})
export default class EventBanner extends Vue {
@Prop({ required: true, default: null })
picture!: IMedia | null;
}
</script>
<style lang="scss" scoped>
.banner-container {
display: flex;
justify-content: center;
height: 30vh;
}
::v-deep img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 50%;
}
</style>

View File

@ -4,12 +4,11 @@
:to="{ name: 'Event', params: { uuid: event.uuid } }"
>
<div class="card-image">
<figure
class="image is-16by9"
:style="`background-image: url('${
event.picture ? event.picture.url : '/img/mobilizon_default_card.png'
}')`"
>
<figure class="image is-16by9">
<lazy-image-wrapper
:picture="event.picture"
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
/>
<div
class="tag-container"
v-if="event.tags || event.status !== EventStatus.CONFIRMED"
@ -34,6 +33,7 @@
<div class="media">
<div class="media-left">
<date-calendar-icon
:small="true"
v-if="!mergedOptions.hideDate"
:date="event.beginsOn"
/>
@ -103,6 +103,7 @@
import { IEvent, IEventCardOptions } from "@/types/event.model";
import { Component, Prop, Vue } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import { Actor, Person } from "@/types/actor";
import { EventStatus, ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name";
@ -110,6 +111,7 @@ import RouteName from "../../router/name";
@Component({
components: {
DateCalendarIcon,
LazyImageWrapper,
},
})
export default class EventCard extends Vue {
@ -220,6 +222,22 @@ a.card {
.card-content {
padding: 0.5rem;
& > .media {
position: relative;
display: flex;
flex-direction: column;
& > .media-left {
margin-top: -15px;
height: 0;
display: flex;
align-items: flex-end;
align-self: flex-start;
margin-bottom: 15px;
margin-left: 0rem;
}
}
.event-title {
font-size: 1.2rem;
line-height: 1.25rem;

View File

@ -7,11 +7,14 @@
{{ displayNameAndUsername(participation.actor) }}
</div>
<div class="list-card">
<div class="date-component">
<date-calendar-icon
:date="participation.event.beginsOn"
:small="true"
/>
</div>
<div class="content">
<div class="title-wrapper">
<div class="date-component">
<date-calendar-icon :date="participation.event.beginsOn" />
</div>
<router-link
:to="{
name: RouteName.EVENT,
@ -21,7 +24,7 @@
<h3 class="title">{{ participation.event.title }}</h3>
</router-link>
</div>
<div class="participation-actor has-text-grey">
<div class="participation-actor">
<span>
<b-icon
icon="earth"
@ -47,8 +50,11 @@
"
>{{ participation.event.physicalAddress.locality }} -</span
>
<span>
<i18n tag="span" path="Organized by {name}">
<i18n
tag="span"
path="Organized by {name}"
v-if="organizerActor.id !== currentActor.id"
>
<popover-actor-card
slot="name"
:actor="organizerActor"
@ -57,7 +63,7 @@
{{ organizerActor.displayName() }}
</popover-actor-card>
</i18n>
</span>
<span v-else>{{ $t("Organized by you") }}</span>
</div>
<div>
<span
@ -113,7 +119,9 @@
$tc(
"{count} requests waiting",
participation.event.participantStats.notApproved,
{ count: participation.event.participantStats.notApproved }
{
count: participation.event.participantStats.notApproved,
}
)
}}
</b-button>
@ -344,6 +352,7 @@ article.box {
.list-card {
display: flex;
align-items: center;
padding: 0 6px;
.actions {
padding-right: 7.5px;

View File

@ -4,7 +4,7 @@
<div class="content column">
<div class="title-wrapper">
<div class="date-component">
<date-calendar-icon :date="event.beginsOn" />
<date-calendar-icon :date="event.beginsOn" :small="true" />
</div>
<router-link
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"

View File

@ -23,7 +23,7 @@ export default class EventMetadataBlock extends Vue {
h2 {
font-size: 1.8rem;
font-weight: 500;
color: #f7ba30;
color: $violet;
}
div.eventMetadataBlock {

View File

@ -3,7 +3,11 @@
class="event-minimalist-card-wrapper"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<date-calendar-icon class="calendar-icon" :date="event.beginsOn" />
<date-calendar-icon
class="calendar-icon"
:date="event.beginsOn"
:small="true"
/>
<div class="title-info-wrapper">
<p class="event-minimalist-title">{{ event.title }}</p>
<p v-if="event.physicalAddress" class="has-text-grey">

View File

@ -66,7 +66,9 @@ export default class OrganizerPicker extends Vue {
return this.value;
}
if (this.currentActor) {
return this.currentActor;
return this.identities.find(
(identity) => identity.id === this.currentActor.id
);
}
return undefined;
}

View File

@ -110,6 +110,7 @@ import { IActor, IGroup, IPerson, usernameWithDomain } from "../../types/actor";
import OrganizerPicker from "./OrganizerPicker.vue";
import {
CURRENT_ACTOR_CLIENT,
IDENTITIES,
LOGGED_USER_MEMBERSHIPS,
} from "../../graphql/actor";
import { Paginate } from "../../types/paginate";
@ -152,6 +153,7 @@ const MEMBER_ROLES = [
},
update: (data) => data.loggedUser.memberships,
},
identities: IDENTITIES,
},
})
export default class OrganizerPickerWrapper extends Vue {
@ -161,6 +163,8 @@ export default class OrganizerPickerWrapper extends Vue {
currentActor!: IPerson;
identities!: IPerson[];
isComponentModalActive = false;
@Prop({ type: Array, required: false, default: () => [] })
@ -200,7 +204,9 @@ export default class OrganizerPickerWrapper extends Vue {
return this.value;
}
if (this.currentActor) {
return this.currentActor;
return this.identities.find(
(identity) => identity.id === this.currentActor.id
);
}
return undefined;
}

View File

@ -115,12 +115,13 @@ footer.footer {
flex: 1;
max-width: 40rem;
@include mobile {
max-width: 400px;
max-width: 100%;
}
}
div.content {
flex: 1;
padding-top: 10px;
}
ul {
@ -131,6 +132,7 @@ footer.footer {
li {
display: inline-flex;
margin: auto 5px;
padding: 2px 0;
a {
font-size: 1.1rem;
}
@ -143,9 +145,12 @@ footer.footer {
text-decoration-color: $secondary;
}
::v-deep span.select select {
::v-deep span.select {
select,
option {
background: $background-color;
color: $white;
}
}
}
</style>

View File

@ -0,0 +1,34 @@
<template>
<canvas ref="canvas" width="32" height="32" />
</template>
<script lang="ts">
import { decode } from "blurhash";
import { Component, Prop, Ref, Vue } from "vue-property-decorator";
@Component
export default class extends Vue {
@Prop({ type: String, required: true }) hash!: string;
@Prop({ type: Number, default: 1 }) aspectRatio!: string;
@Ref("canvas") readonly canvas!: any;
mounted(): void {
const pixels = decode(this.hash, 32, 32);
const imageData = new ImageData(pixels, 32, 32);
const context = this.canvas.getContext("2d");
context.putImageData(imageData, 0, 0);
}
}
</script>
<style lang="scss" scoped>
canvas {
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<div ref="wrapper" class="wrapper" v-bind="$attrs">
<div class="relative container">
<!-- Show the placeholder as background -->
<blurhash-img
v-if="blurhash"
:hash="blurhash"
:aspect-ratio="height / width"
class="top-0 left-0 transition-opacity duration-500"
:class="isLoaded ? 'opacity-0' : 'opacity-100'"
/>
<!-- Show the real image on the top and fade in after loading -->
<img
ref="image"
:width="width"
:height="height"
class="absolute top-0 left-0 transition-opacity duration-500"
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
alt=""
/>
</div>
</div>
</template>
<script lang="ts">
import { Prop, Component, Vue, Ref, Watch } from "vue-property-decorator";
import BlurhashImg from "./BlurhashImg.vue";
@Component({
components: {
BlurhashImg,
},
})
export default class LazyImage extends Vue {
@Prop({ type: String, required: true }) src!: string;
@Prop({ type: String, required: false, default: null }) blurhash!: string;
@Prop({ type: Number, default: 1 }) width!: number;
@Prop({ type: Number, default: 1 }) height!: number;
inheritAttrs = false;
isLoaded = false;
observer!: IntersectionObserver;
@Ref("wrapper") readonly wrapper!: any;
@Ref("image") image!: any;
mounted(): void {
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.onEnter();
}
});
this.observer.observe(this.wrapper);
}
unmounted(): void {
this.observer.disconnect();
}
onEnter(): void {
// Image is visible (means: has entered the viewport),
// so start loading by setting the src attribute
this.image.src = this.src;
this.image.onload = () => {
// Image is loaded, so start fading in
this.isLoaded = true;
};
}
@Watch("src")
updateImageWithSrcChange(): void {
this.onEnter();
}
}
</script>
<style lang="scss" scoped>
.relative {
position: relative;
}
.absolute {
position: absolute;
}
.top-0 {
top: 0;
}
.left-0 {
left: 0;
}
.opacity-100 {
opacity: 100%;
}
.opacity-0 {
opacity: 0;
}
.transition-opacity {
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.duration-500 {
transition-duration: 0.5s;
}
.wrapper,
.container {
display: flex;
flex: 1;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 50%;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<lazy-image
v-if="pictureOrDefault.url !== undefined"
:src="pictureOrDefault.url"
:width="pictureOrDefault.metadata.width"
:height="pictureOrDefault.metadata.height"
:blurhash="pictureOrDefault.metadata.blurhash"
/>
</template>
<script lang="ts">
import { IMedia } from "@/types/media.model";
import { Component, Prop, Vue } from "vue-property-decorator";
import LazyImage from "../Image/LazyImage.vue";
@Component({
components: {
LazyImage,
},
})
export default class LazyImageWrapper extends Vue {
@Prop({ required: true, default: null })
picture!: IMedia | null;
get pictureOrDefault(): Partial<IMedia> {
return {
url:
this?.picture?.url === null
? "/img/mobilizon_default_card.png"
: this?.picture?.url,
metadata: {
width: this?.picture?.metadata?.width || 630,
height: this?.picture?.metadata?.height || 350,
blurhash:
this?.picture?.metadata?.blurhash ||
"MCHKI4El-P-U}+={R-WWoes,Iu-P=?R,xD",
},
};
}
}
</script>

View File

@ -1,35 +1,40 @@
import gql from "graphql-tag";
const $addressFragment = `
id,
description,
geom,
street,
locality,
postalCode,
region,
country,
type,
url,
export const ADDRESS_FRAGMENT = gql`
fragment AdressFragment on Address {
id
description
geom
street
locality
postalCode
region
country
type
url
originId
}
`;
export const ADDRESS = gql`
query ($query: String!, $locale: String, $type: AddressSearchType) {
searchAddress(
query: $query,
locale: $locale,
type: $type
) {
${$addressFragment}
searchAddress(query: $query, locale: $locale, type: $type) {
...AdressFragment
}
}
${ADDRESS_FRAGMENT}
`;
export const REVERSE_GEOCODE = gql`
query ($latitude: Float!, $longitude: Float!, $zoom: Int, $locale: String) {
reverseGeocode(latitude: $latitude, longitude: $longitude, zoom: $zoom, locale: $locale) {
${$addressFragment}
reverseGeocode(
latitude: $latitude
longitude: $longitude
zoom: $zoom
locale: $locale
) {
...AdressFragment
}
}
${ADDRESS_FRAGMENT}
`;

View File

@ -1,179 +1,189 @@
import gql from "graphql-tag";
import { ADDRESS_FRAGMENT } from "./address";
import { TAG_FRAGMENT } from "./tags";
const participantQuery = `
role,
id,
const PARTICIPANT_QUERY_FRAGMENT = gql`
fragment ParticipantQuery on Participant {
role
id
actor {
preferredUsername,
preferredUsername
avatar {
id
url
},
name,
id,
}
name
id
domain
},
}
event {
id,
id
uuid
},
}
metadata {
cancellationToken,
cancellationToken
message
},
}
insertedAt
`;
const participantsQuery = `
total,
elements {
${participantQuery}
}
`;
const physicalAddressQuery = `
description,
street,
locality,
postalCode,
region,
country,
geom,
type,
id,
originId
const PARTICIPANTS_QUERY_FRAGMENT = gql`
fragment ParticipantsQuery on PaginatedParticipantList {
total
elements {
...ParticipantQuery
}
}
${PARTICIPANT_QUERY_FRAGMENT}
`;
const tagsQuery = `
id,
slug,
title
`;
const optionsQuery = `
maximumAttendeeCapacity,
remainingAttendeeCapacity,
showRemainingAttendeeCapacity,
anonymousParticipation,
showStartTime,
showEndTime,
const EVENT_OPTIONS_FRAGMENT = gql`
fragment EventOptions on EventOptions {
maximumAttendeeCapacity
remainingAttendeeCapacity
showRemainingAttendeeCapacity
anonymousParticipation
showStartTime
showEndTime
offers {
price,
priceCurrency,
price
priceCurrency
url
},
}
participationConditions {
title,
content,
title
content
url
},
attendees,
program,
commentModeration,
showParticipationPrice,
hideOrganizerWhenGroupEvent,
__typename
}
attendees
program
commentModeration
showParticipationPrice
hideOrganizerWhenGroupEvent
}
`;
const FULL_EVENT_FRAGMENT = gql`
fragment FullEvent on Event {
id
uuid
url
local
title
description
beginsOn
endsOn
status
visibility
joinOptions
draft
picture {
id
url
name
metadata {
width
height
blurhash
}
}
publishAt
onlineAddress
phoneAddress
physicalAddress {
...AdressFragment
}
organizerActor {
avatar {
id
url
}
preferredUsername
domain
name
url
id
summary
}
contacts {
avatar {
id
url
}
preferredUsername
name
summary
domain
url
id
}
attributedTo {
avatar {
id
url
}
preferredUsername
name
summary
domain
url
id
}
participantStats {
going
notApproved
participant
}
tags {
...TagFragment
}
relatedEvents {
id
uuid
title
beginsOn
picture {
id
url
name
metadata {
width
height
blurhash
}
}
physicalAddress {
id
description
}
organizerActor {
id
avatar {
id
url
}
preferredUsername
domain
name
}
}
options {
...EventOptions
}
}
${ADDRESS_FRAGMENT}
${TAG_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
`;
export const FETCH_EVENT = gql`
query FetchEvent($uuid: UUID!) {
event(uuid: $uuid) {
id,
uuid,
url,
local,
title,
description,
beginsOn,
endsOn,
status,
visibility,
joinOptions,
draft,
picture {
id
url
name
},
publishAt,
onlineAddress,
phoneAddress,
physicalAddress {
${physicalAddressQuery}
}
organizerActor {
avatar {
id
url
},
preferredUsername,
domain,
name,
url,
id,
summary
},
contacts {
avatar {
id
url,
}
preferredUsername,
name,
summary,
domain,
url,
id
},
attributedTo {
avatar {
id
url,
}
preferredUsername,
name,
summary,
domain,
url,
id
},
participantStats {
going,
notApproved,
participant
},
tags {
${tagsQuery}
},
relatedEvents {
id
uuid,
title,
beginsOn,
picture {
id,
url
}
physicalAddress {
id
description
},
organizerActor {
id
avatar {
id
url,
},
preferredUsername,
domain,
name,
}
},
options {
${optionsQuery}
}
...FullEvent
}
}
${FULL_EVENT_FRAGMENT}
`;
export const FETCH_EVENT_BASIC = gql`
@ -240,252 +250,129 @@ export const FETCH_EVENTS = gql`
# },
category
tags {
slug
title
...TagFragment
}
}
}
}
${TAG_FRAGMENT}
`;
export const CREATE_EVENT = gql`
mutation createEvent(
$organizerActorId: ID!,
$attributedToId: ID,
$title: String!,
$description: String!,
$beginsOn: DateTime!,
$endsOn: DateTime,
$status: EventStatus,
$visibility: EventVisibility,
$joinOptions: EventJoinOptions,
$draft: Boolean,
$tags: [String],
$picture: MediaInput,
$onlineAddress: String,
$phoneAddress: String,
$category: String,
$physicalAddress: AddressInput,
$options: EventOptionsInput,
$organizerActorId: ID!
$attributedToId: ID
$title: String!
$description: String!
$beginsOn: DateTime!
$endsOn: DateTime
$status: EventStatus
$visibility: EventVisibility
$joinOptions: EventJoinOptions
$draft: Boolean
$tags: [String]
$picture: MediaInput
$onlineAddress: String
$phoneAddress: String
$category: String
$physicalAddress: AddressInput
$options: EventOptionsInput
$contacts: [Contact]
) {
createEvent(
organizerActorId: $organizerActorId,
attributedToId: $attributedToId,
title: $title,
description: $description,
beginsOn: $beginsOn,
endsOn: $endsOn,
status: $status,
visibility: $visibility,
joinOptions: $joinOptions,
draft: $draft,
tags: $tags,
picture: $picture,
onlineAddress: $onlineAddress,
phoneAddress: $phoneAddress,
category: $category,
organizerActorId: $organizerActorId
attributedToId: $attributedToId
title: $title
description: $description
beginsOn: $beginsOn
endsOn: $endsOn
status: $status
visibility: $visibility
joinOptions: $joinOptions
draft: $draft
tags: $tags
picture: $picture
onlineAddress: $onlineAddress
phoneAddress: $phoneAddress
category: $category
physicalAddress: $physicalAddress
options: $options,
options: $options
contacts: $contacts
) {
id,
uuid,
title,
url,
local,
description,
beginsOn,
endsOn,
status,
visibility,
joinOptions,
draft,
picture {
id
url
},
publishAt,
category,
onlineAddress,
phoneAddress,
physicalAddress {
${physicalAddressQuery}
},
attributedTo {
id,
domain,
name,
url,
preferredUsername,
avatar {
id
url
}
},
organizerActor {
avatar {
id
url
},
preferredUsername,
domain,
name,
url,
id,
},
contacts {
avatar {
id
url
},
preferredUsername,
domain,
name,
url,
id,
},
participantStats {
going,
notApproved,
participant
},
tags {
${tagsQuery}
},
options {
${optionsQuery}
}
...FullEvent
}
}
${FULL_EVENT_FRAGMENT}
`;
export const EDIT_EVENT = gql`
mutation updateEvent(
$id: ID!,
$title: String,
$description: String,
$beginsOn: DateTime,
$endsOn: DateTime,
$status: EventStatus,
$visibility: EventVisibility,
$joinOptions: EventJoinOptions,
$draft: Boolean,
$tags: [String],
$picture: MediaInput,
$onlineAddress: String,
$phoneAddress: String,
$organizerActorId: ID,
$attributedToId: ID,
$category: String,
$physicalAddress: AddressInput,
$options: EventOptionsInput,
$id: ID!
$title: String
$description: String
$beginsOn: DateTime
$endsOn: DateTime
$status: EventStatus
$visibility: EventVisibility
$joinOptions: EventJoinOptions
$draft: Boolean
$tags: [String]
$picture: MediaInput
$onlineAddress: String
$phoneAddress: String
$organizerActorId: ID
$attributedToId: ID
$category: String
$physicalAddress: AddressInput
$options: EventOptionsInput
$contacts: [Contact]
) {
updateEvent(
eventId: $id,
title: $title,
description: $description,
beginsOn: $beginsOn,
endsOn: $endsOn,
status: $status,
visibility: $visibility,
joinOptions: $joinOptions,
draft: $draft,
tags: $tags,
picture: $picture,
onlineAddress: $onlineAddress,
phoneAddress: $phoneAddress,
organizerActorId: $organizerActorId,
attributedToId: $attributedToId,
category: $category,
eventId: $id
title: $title
description: $description
beginsOn: $beginsOn
endsOn: $endsOn
status: $status
visibility: $visibility
joinOptions: $joinOptions
draft: $draft
tags: $tags
picture: $picture
onlineAddress: $onlineAddress
phoneAddress: $phoneAddress
organizerActorId: $organizerActorId
attributedToId: $attributedToId
category: $category
physicalAddress: $physicalAddress
options: $options,
options: $options
contacts: $contacts
) {
id,
uuid,
title,
url,
local,
description,
beginsOn,
endsOn,
status,
visibility,
joinOptions,
draft,
picture {
id
url
},
publishAt,
category,
onlineAddress,
phoneAddress,
physicalAddress {
${physicalAddressQuery}
},
attributedTo {
id,
domain,
name,
url,
preferredUsername,
avatar {
id
url
}
},
contacts {
avatar {
id
url
},
preferredUsername,
domain,
name,
url,
id,
},
organizerActor {
avatar {
id
url
},
preferredUsername,
domain,
name,
url,
id,
},
participantStats {
going,
notApproved,
participant
},
tags {
${tagsQuery}
},
options {
${optionsQuery}
}
...FullEvent
}
}
${FULL_EVENT_FRAGMENT}
`;
export const JOIN_EVENT = gql`
mutation JoinEvent($eventId: ID!, $actorId: ID!, $email: String, $message: String, $locale: String) {
mutation JoinEvent(
$eventId: ID!
$actorId: ID!
$email: String
$message: String
$locale: String
) {
joinEvent(
eventId: $eventId,
actorId: $actorId,
email: $email,
message: $message,
eventId: $eventId
actorId: $actorId
email: $email
message: $message
locale: $locale
) {
${participantQuery}
...ParticipantsQuery
}
}
${PARTICIPANTS_QUERY_FRAGMENT}
`;
export const LEAVE_EVENT = gql`
@ -534,20 +421,21 @@ export const DELETE_EVENT = gql`
export const PARTICIPANTS = gql`
query Participants($uuid: UUID!, $page: Int, $limit: Int, $roles: String) {
event(uuid: $uuid) {
id,
uuid,
title,
id
uuid
title
participants(page: $page, limit: $limit, roles: $roles) {
${participantsQuery}
},
...ParticipantsQuery
}
participantStats {
going,
notApproved,
rejected,
going
notApproved
rejected
participant
}
}
}
${PARTICIPANTS_QUERY_FRAGMENT}
`;
export const EVENT_PERSON_PARTICIPATION = gql`

View File

@ -150,7 +150,7 @@ export default class EventMixin extends mixins(Vue) {
}
private async deleteEvent(event: IEvent) {
const eventTitle = event.title;
const { title: eventTitle, id: eventId } = event;
try {
await this.$apollo.mutate<IParticipant>({
@ -159,6 +159,9 @@ export default class EventMixin extends mixins(Vue) {
eventId: event.id,
},
});
const cache = this.$apollo.getClient().cache as InMemoryCache;
cache.evict({ id: `Event:${eventId}` });
cache.gc();
/**
* When the event corresponding has been deleted (by the organizer).
* A notification is already triggered.

View File

@ -3,6 +3,7 @@ export interface IMedia {
url: string;
name: string;
alt: string;
metadata: IMediaMetadata;
}
export interface IMediaUpload {
@ -10,3 +11,9 @@ export interface IMediaUpload {
name: string;
alt: string | null;
}
export interface IMediaMetadata {
width?: number;
height?: number;
blurhash?: string;
}

View File

@ -58,6 +58,7 @@ $danger-invert: findColorInvert($danger);
$link: $primary;
$link-invert: $primary-invert;
$text: $violet-1;
$grey: #757575;
$colors: map-merge(
$colors,

View File

@ -782,25 +782,18 @@ export default class EditEvent extends Vue {
*/
private postCreateOrUpdate(store: any, updateEvent: IEvent) {
const resultEvent: IEvent = { ...updateEvent };
resultEvent.organizerActor = this.event.organizerActor;
resultEvent.relatedEvents = [];
store.writeQuery({
query: FETCH_EVENT,
variables: { uuid: updateEvent.uuid },
data: { event: resultEvent },
});
console.log(resultEvent);
if (!updateEvent.draft) {
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: {
eventId: updateEvent.id,
name: this.event.organizerActor?.preferredUsername,
eventId: resultEvent.id,
name: resultEvent.organizerActor?.preferredUsername,
},
data: {
person: {
__typename: "Person",
id: this.event?.organizerActor?.id,
id: resultEvent?.organizerActor?.id,
participations: {
__typename: "PaginatedParticipantList",
total: 1,
@ -811,11 +804,11 @@ export default class EditEvent extends Vue {
role: ParticipantRole.CREATOR,
actor: {
__typename: "Actor",
id: this.event?.organizerActor?.id,
id: resultEvent?.organizerActor?.id,
},
event: {
__typename: "Event",
id: updateEvent.id,
id: resultEvent.id,
},
},
],
@ -859,7 +852,7 @@ export default class EditEvent extends Vue {
* Build variables for Event GraphQL creation query
*/
private async buildVariables() {
let res = this.event.toEditJSON();
let res = new EventModel(this.event).toEditJSON();
const organizerActor = this.event.organizerActor?.id
? this.event.organizerActor
: this.organizerActor;

View File

@ -1,18 +1,14 @@
<template>
<div class="container">
<transition appear name="fade" mode="out-in">
<div>
<div
class="header-picture"
v-if="event.picture"
:style="`background-image: url('${event.picture.url}')`"
/>
<div class="header-picture-default" v-else />
<section class="section intro">
<div class="columns">
<div class="column is-1-tablet">
<div class="wrapper">
<event-banner :picture="event.picture" />
<div class="intro-wrapper">
<div class="date-calendar-icon-wrapper">
<date-calendar-icon :date="event.beginsOn" />
</div>
<section class="intro">
<div class="columns">
<div class="column">
<h1 class="title" style="margin: 0">{{ event.title }}</h1>
<div class="organizer">
@ -294,6 +290,7 @@
</div>
</div>
</section>
</div>
<div class="event-description-wrapper">
<aside class="event-metadata">
<div class="sticky">
@ -662,6 +659,7 @@ import { IConfig } from "../../types/config.model";
import Subtitle from "../../components/Utils/Subtitle.vue";
import Tag from "../../components/Tag.vue";
import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue";
import EventBanner from "../../components/Event/EventBanner.vue";
import ActorCard from "../../components/Account/ActorCard.vue";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
import { IParticipant } from "../../types/participant.model";
@ -683,6 +681,7 @@ import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
Tag,
ActorCard,
PopoverActorCard,
EventBanner,
"map-leaflet": () =>
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
ShareEventModal: () =>
@ -1308,18 +1307,6 @@ export default class Event extends EventMixin {
opacity: 0;
}
.header-picture,
.header-picture-default {
height: 400px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.header-picture-default {
background-image: url("../../../public/img/mobilizon_default_card.png");
}
div.sidebar {
display: flex;
flex-wrap: wrap;
@ -1353,7 +1340,7 @@ div.sidebar {
}
}
.intro.section {
.intro {
background: white;
.is-3-tablet {
@ -1570,4 +1557,30 @@ a.participations-link {
border: 0;
cursor: auto;
}
.wrapper,
.intro-wrapper {
display: flex;
flex-direction: column;
}
.intro-wrapper {
position: relative;
padding: 0 16px 16px;
background: #fff;
.date-calendar-icon-wrapper {
margin-top: 16px;
height: 0;
display: flex;
align-items: flex-end;
align-self: flex-start;
margin-bottom: 7px;
margin-left: 0rem;
}
}
.title {
margin: 0;
font-size: 2rem;
}
</style>

View File

@ -189,10 +189,7 @@
}}</b-message>
</section>
<!-- Your upcoming events -->
<section
v-if="currentActor.id && goingToEvents.size > 0"
class="container"
>
<section v-if="canShowMyUpcomingEvents" class="container">
<h3 class="title">{{ $t("Your upcoming events") }}</h3>
<b-loading :active.sync="$apollo.loading" />
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
@ -236,7 +233,7 @@
</span>
</section>
<!-- Last week events -->
<section v-if="currentActor && lastWeekEvents.length > 0">
<section v-if="canShowLastWeekEvents">
<h3 class="title">{{ $t("Last week") }}</h3>
<b-loading :active.sync="$apollo.loading" />
<div>
@ -250,7 +247,7 @@
</div>
</section>
<!-- Events close to you -->
<section class="events-close" v-if="closeEvents.total > 0">
<section class="events-close" v-if="canShowCloseEvents">
<h2 class="is-size-2 has-text-weight-bold">
{{ $t("Events nearby") }}
</h2>
@ -285,7 +282,12 @@
</div>
</div>
</section>
<hr class="home-separator" />
<hr
class="home-separator"
v-if="
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
"
/>
<section class="events-recent">
<h2 class="is-size-2 has-text-weight-bold">
{{ $t("Last published events") }}
@ -586,6 +588,18 @@ export default class Home extends Vue {
});
}
}
get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}
get canShowLastWeekEvents(): boolean {
return this.currentActor && this.lastWeekEvents.length > 0;
}
get canShowCloseEvents(): boolean {
return this.closeEvents.total > 0;
}
}
</script>

View File

@ -15,6 +15,7 @@ import { CommentModeration } from "@/types/enums";
import { IEvent } from "@/types/event.model";
import {
eventCommentThreadsMock,
eventNoCommentThreadsMock,
newCommentForEventMock,
newCommentForEventResponse,
} from "../../mocks/event";
@ -35,7 +36,7 @@ const eventData = {
};
describe("CommentTree", () => {
let wrapper: Wrapper<Vue>;
let mockClient: MockApolloClient;
let mockClient: MockApolloClient | null;
let apolloProvider;
let requestHandlers: Record<string, RequestHandler>;
const cache = new InMemoryCache({ addTypename: false });
@ -83,24 +84,10 @@ describe("CommentTree", () => {
});
};
it("renders an empty comment tree", async () => {
generateWrapper();
expect(wrapper.exists()).toBe(true);
expect(wrapper.find(".loading").text()).toBe("Loading comments…");
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick(); // because of the <transition>
expect(wrapper.find(".no-comments").text()).toBe("No comments yet");
expect(wrapper.html()).toMatchSnapshot();
});
it("renders a comment tree with comments", async () => {
generateWrapper();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick(); // because of the <transition>
await flushPromises();
expect(wrapper.exists()).toBe(true);
expect(
@ -150,4 +137,21 @@ describe("CommentTree", () => {
}
}
});
it("renders an empty comment tree", async () => {
generateWrapper({
eventCommentThreadsQueryHandler: jest
.fn()
.mockResolvedValue(eventNoCommentThreadsMock),
});
expect(requestHandlers.eventCommentThreadsQueryHandler).toHaveBeenCalled();
expect(wrapper.exists()).toBe(true);
expect(wrapper.find(".loading").text()).toBe("Loading comments…");
await flushPromises();
expect(wrapper.find(".no-comments").text()).toBe("No comments yet");
expect(wrapper.html()).toMatchSnapshot();
});
});

View File

@ -2,25 +2,62 @@
exports[`CommentTree renders a comment tree with comments 1`] = `
<div>
<form class="new-comment">
<!---->
<article class="media">
<figure class="media-left">
<identity-picker-wrapper-stub value="[object Object]"></identity-picker-wrapper-stub>
</figure>
<div class="media-content">
<div class="field">
<div class="field">
<p class="control">
<editor-stub mode="comment" value=""></editor-stub>
</p>
<!---->
</div>
<!---->
</div>
</div>
<div class="send-comment">
<b-button-stub type="is-primary" iconleft="send" nativetype="submit" tag="button" aria-label="Post a comment" class="comment-button-submit"></b-button-stub>
</div>
</article>
</form>
<transition-group-stub name="comment-empty-list" mode="out-in">
<transition-group-stub tag="ul" name="comment-list" class="comment-list">
<comment-stub comment="[object Object]" event="[object Object]" class="root-comment"></comment-stub>
<comment-stub comment="[object Object]" event="[object Object]" class="root-comment"></comment-stub>
</transition-group-stub>
<div class="no-comments"><span>No comments yet</span></div>
</transition-group-stub>
</div>
`;
exports[`CommentTree renders an empty comment tree 1`] = `
<div>
<form class="new-comment">
<!---->
<article class="media">
<figure class="media-left">
<identity-picker-wrapper-stub value="[object Object]"></identity-picker-wrapper-stub>
</figure>
<div class="media-content">
<div class="field">
<div class="field">
<p class="control">
<editor-stub mode="comment" value=""></editor-stub>
</p>
<!---->
</div>
<!---->
</div>
</div>
<div class="send-comment">
<b-button-stub type="is-primary" iconleft="send" nativetype="submit" tag="button" aria-label="Post a comment" class="comment-button-submit"></b-button-stub>
</div>
</article>
</form>
<transition-group-stub name="comment-empty-list" mode="out-in">
<transition-group-stub tag="ul" name="comment-list" class="comment-list">
<comment-stub comment="[object Object]" event="[object Object]" class="root-comment"></comment-stub>
<comment-stub comment="[object Object]" event="[object Object]" class="root-comment"></comment-stub>
</transition-group-stub>
<div class="no-comments"><span>No comments yet</span></div>
</transition-group-stub>
</div>

View File

@ -63,6 +63,17 @@ export const joinEventMock = {
locale: "en_US",
};
export const eventNoCommentThreadsMock = {
data: {
event: {
__typename: "Event",
id: "1",
uuid: "f37910ea-fd5a-4756-9679-00971f3f4106",
comments: [],
},
},
};
export const eventCommentThreadsMock = {
data: {
event: {

View File

@ -3416,6 +3416,11 @@ bluebird@^3.1.1, bluebird@^3.7.2:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
blurhash@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.3.tgz#dc325af7da836d07a0861d830bdd63694382483e"
integrity sha512-yUhPJvXexbqbyijCIE/T2NCXcj9iNPhWmOKbPTuR/cm7Q5snXYIfnVnz6m7MWOXxODMz/Cr3UcVkRdHiuDVRDw==
body-parser@1.19.0:
version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"

View File

@ -352,18 +352,14 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
end
def make_media_data(media) when is_map(media) do
with {:ok, %{"url" => [%{"href" => url, "mediaType" => content_type}], "size" => size}} <-
with {:ok, %{url: url} = uploaded} <-
Mobilizon.Web.Upload.store(media.file),
{:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)},
{:ok, %Media{file: _file} = media} <-
Mobilizon.Medias.create_media(%{
"file" => %{
"url" => url,
"name" => media.name,
"content_type" => content_type,
"size" => size
},
"actor_id" => media.actor_id
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
actor_id: media.actor_id
}) do
Converter.Media.model_to_as(media)
else

View File

@ -143,7 +143,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
when code in 200..299 <- RemoteMediaDownloaderClient.get(url),
name <- name || Parser.get_filename_from_response(response_headers, url) || default_name,
{:ok, file} <- Upload.store(%{body: body, name: name}) do
file
Map.take(file, [:content_type, :name, :url, :size])
end
end
end

View File

@ -40,17 +40,13 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Media do
)
when is_binary(media_url) do
with {:ok, %{body: body}} <- Tesla.get(media_url, opts: @http_options),
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
{:ok, %{url: url} = uploaded} <-
Upload.store(%{body: body, name: name}),
{:media_exists, nil} <- {:media_exists, Medias.get_media_by_url(url)} do
Medias.create_media(%{
"file" => %{
"url" => url,
"name" => name,
"content_type" => content_type,
"size" => size
},
"actor_id" => actor_id
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
actor_id: actor_id
})
else
{:media_exists, %MediaModel{file: _file} = media} ->

View File

@ -144,7 +144,7 @@ defmodule Mobilizon.Federation.WebFinger do
@spec find_webfinger_endpoint(String.t()) :: String.t()
def find_webfinger_endpoint(domain) when is_binary(domain) do
with {:ok, %{body: body}} <- fetch_document("http://#{domain}/.well-known/host-meta"),
link_template <- find_link_from_template(body) do
link_template when is_binary(link_template) <- find_link_from_template(body) do
{:ok, link_template}
end
end
@ -203,6 +203,9 @@ defmodule Mobilizon.Federation.WebFinger do
xpath(doc, ~x"//Link[@rel=\"lrdd\"][@type=\"application/json\"]/@template"s),
res when res in [nil, ""] <- xpath(doc, ~x"//Link[@rel=\"lrdd\"]/@template"s),
do: {:error, :link_not_found}
catch
:exit, _e ->
{:error, :link_not_found}
end
@spec fetch_document(String.t()) :: Tesla.Env.result()

View File

@ -52,14 +52,17 @@ defmodule Mobilizon.GraphQL.API.Events do
defp process_picture(%{media_id: _picture_id} = args, _), do: args
defp process_picture(%{media: media}, %Actor{id: actor_id}) do
%{
file:
with uploaded when is_map(uploaded) <-
media
|> Map.get(:file)
|> Utils.make_media_data(description: Map.get(media, :name)),
|> Utils.make_media_data(description: Map.get(media, :name)) do
%{
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
actor_id: actor_id
}
end
end
@spec extract_pictures_from_event_body(map(), Actor.t()) :: map()
defp extract_pictures_from_event_body(

View File

@ -47,7 +47,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do
%{context: %{current_user: %User{} = user}}
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:ok, %{name: _name, url: url, content_type: content_type, size: size}} <-
{:ok,
%{
name: _name,
url: url,
content_type: content_type,
size: size
} = uploaded} <-
Mobilizon.Web.Upload.store(file),
args <-
args
@ -55,7 +61,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do
|> Map.put(:size, size)
|> Map.put(:content_type, content_type),
{:ok, media = %Media{}} <-
Medias.create_media(%{"file" => args, "actor_id" => actor_id}) do
Medias.create_media(%{
file: args,
actor_id: actor_id,
metadata: Map.take(uploaded, [:width, :height, :blurhash])
}) do
{:ok, transform_media(media)}
else
{:error, :mime_type_not_allowed} ->
@ -124,13 +134,14 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do
def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
@spec transform_media(Media.t()) :: map()
defp transform_media(%Media{id: id, file: file}) do
defp transform_media(%Media{id: id, file: file, metadata: metadata}) do
%{
name: file.name,
url: file.url,
id: id,
content_type: file.content_type,
size: file.size
size: file.size,
metadata: metadata
}
end

View File

@ -215,14 +215,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
defp process_picture(%{media_id: _picture_id} = args, _), do: args
defp process_picture(%{media: media}, %Actor{id: actor_id}) do
%{
file:
with uploaded when is_map(uploaded) <-
media
|> Map.get(:file)
|> Utils.make_media_data(description: Map.get(media, :name)),
|> Utils.make_media_data(description: Map.get(media, :name)) do
%{
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
actor_id: actor_id
}
end
end
@spec extract_pictures_from_post_body(map(), String.t()) :: map()
defp extract_pictures_from_post_body(%{body: body} = args, actor_id) do

View File

@ -14,6 +14,7 @@ defmodule Mobilizon.GraphQL.Schema.MediaType do
field(:url, :string, description: "The media's full URL")
field(:content_type, :string, description: "The media's detected content type")
field(:size, :integer, description: "The media's size")
field(:metadata, :media_metadata, description: "The media's metadata")
end
@desc """
@ -24,6 +25,15 @@ defmodule Mobilizon.GraphQL.Schema.MediaType do
field(:total, :integer, description: "The total number of medias in the list")
end
@desc """
Some metadata associated with a media
"""
object :media_metadata do
field(:width, :integer, description: "The media width (if a picture)")
field(:height, :integer, description: "The media width (if a height)")
field(:blurhash, :string, description: "The media blurhash (if a picture")
end
@desc "An attached media or a link to a media"
input_object :media_input do
# Either a full media object

View File

@ -80,11 +80,12 @@ defmodule Mobilizon.Discussions do
# However, it also excludes all top-level comments with deleted replies from being selected
# |> where([_, r], is_nil(r.deleted_at))
|> group_by([c], c.id)
|> order_by([c], desc: :is_announcement, asc: :published_at)
|> select([c, r], %{c | total_replies: count(r.id)})
end
def query(Comment, _) do
order_by(Comment, [c], asc: :published_at)
order_by(Comment, [c], asc: :is_announcement, asc: :published_at)
end
def query(queryable, _) do

View File

@ -256,7 +256,7 @@ defmodule Mobilizon.Events.Event do
# In case it's a new picture
defp put_picture(%Changeset{} = changeset, _attrs) do
cast_assoc(changeset, :picture)
cast_assoc(changeset, :picture, with: &Media.changeset/2)
end
# Created or updated with draft parameter: don't publish

View File

@ -5,21 +5,32 @@ defmodule Mobilizon.Medias.Media do
use Ecto.Schema
import Ecto.Changeset, only: [cast: 3, cast_embed: 2]
import Ecto.Changeset, only: [cast: 3, cast_embed: 2, cast_embed: 3]
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Medias.File
alias Mobilizon.Medias.Media.Metadata
alias Mobilizon.Posts.Post
@type t :: %__MODULE__{
file: File.t(),
metadata: Metadata.t(),
actor: Actor.t()
}
@metadata_attrs [:height, :width, :blurhash]
schema "medias" do
embeds_one(:file, File, on_replace: :update)
embeds_one :metadata, Metadata, on_replace: :update do
field(:height, :integer)
field(:width, :integer)
field(:blurhash, :string)
end
belongs_to(:actor, Actor)
has_many(:event_picture, Event, foreign_key: :picture_id)
many_to_many(:events, Event, join_through: "events_medias")
@ -36,5 +47,13 @@ defmodule Mobilizon.Medias.Media do
media
|> cast(attrs, [:actor_id])
|> cast_embed(:file)
|> cast_embed(:metadata, with: &metadata_changeset/2)
end
@doc false
@spec changeset(struct(), map) :: Ecto.Changeset.t()
def metadata_changeset(metadata, attrs) do
metadata
|> cast(attrs, @metadata_attrs)
end
end

View File

@ -0,0 +1,47 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/-/blob/develop/lib/pleroma/upload/filter/analyze_metadata.ex
defmodule Mobilizon.Web.Upload.Filter.AnalyzeMetadata do
@moduledoc """
Extracts metadata about the upload, such as width/height
"""
require Logger
alias Mobilizon.Web.Upload
@behaviour Mobilizon.Web.Upload.Filter
@spec filter(Upload.t()) ::
{:ok, :filtered, Upload.t()} | {:ok, :noop} | {:error, String.t()}
def filter(%Upload{tempfile: file, content_type: "image" <> _} = upload) do
image =
file
|> Mogrify.open()
|> Mogrify.verbose()
upload =
upload
|> Map.put(:width, image.width)
|> Map.put(:height, image.height)
|> Map.put(:blurhash, get_blurhash(file))
{:ok, :filtered, upload}
rescue
e in ErlangError ->
Logger.warn("#{__MODULE__}: #{inspect(e)}")
{:ok, :noop}
end
def filter(_), do: {:ok, :noop}
defp get_blurhash(file) do
case :eblurhash.magick(to_charlist(file)) do
{:ok, blurhash} ->
to_string(blurhash)
_ ->
nil
end
end
end

View File

@ -73,12 +73,9 @@ defmodule Mobilizon.Web.Upload do
{:ok, upload} <- Filter.filter(opts.filters, upload),
{:ok, url_spec} <- Uploader.put_file(opts.uploader, upload) do
{:ok,
%{
name: Map.get(opts, :description) || upload.name,
url: url_from_spec(upload, opts.base_url, url_spec),
content_type: upload.content_type,
size: upload.size
}}
upload
|> Map.put(:name, Map.get(opts, :description) || upload.name)
|> Map.put(:url, url_from_spec(upload, opts.base_url, url_spec))}
else
{:error, error} ->
Logger.error(

View File

@ -162,6 +162,9 @@ defmodule Mobilizon.Mixfile do
{:sweet_xml, "~> 0.6.6"},
{:web_push_encryption,
git: "https://github.com/tcitworld/elixir-web-push-encryption", branch: "otp-24"},
{:eblurhash,
git: "https://github.com/zotonic/eblurhash",
ref: "04a0b76eadf4de1be17726f39b6313b88708fd12"},
# Dev and test dependencies
{:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]},
{:ex_machina, "~> 2.3", only: [:dev, :test]},

View File

@ -25,6 +25,7 @@
"dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"},
"earmark": {:hex, :earmark, "1.4.15", "2c7f924bf495ec1f65bd144b355d0949a05a254d0ec561740308a54946a67888", [:mix], [{:earmark_parser, ">= 1.4.13", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "3b1209b85bc9f3586f370f7c363f6533788fb4e51db23aa79565875e7f9999ee"},
"earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"},
"eblurhash": {:git, "https://github.com/zotonic/eblurhash", "04a0b76eadf4de1be17726f39b6313b88708fd12", [ref: "04a0b76eadf4de1be17726f39b6313b88708fd12"]},
"ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"},
"ecto_autoslug_field": {:hex, :ecto_autoslug_field, "2.0.1", "2177c1c253f6dd3efd4b56d1cb76104d0a6ef044c6b9a7a0ad6d32665c4111e5", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm", "a3cc73211f2e75b89a03332183812ebe1ac08be2e25a1df5aa3d1422f92c45c3"},
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},

View File

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddMetadataToMedia do
use Ecto.Migration
def change do
alter table(:medias) do
add(:metadata, :map)
end
end
end

View File

@ -0,0 +1,20 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/-/blob/develop/test/pleroma/upload/filter/analyze_metadata_test.exs
defmodule Mobilizon.Web.Upload.Filter.AnalyzeMetadataTest do
use Mobilizon.DataCase, async: true
alias Mobilizon.Web.Upload.Filter.AnalyzeMetadata
test "adds the image dimensions" do
upload = %Mobilizon.Web.Upload{
name: "an… image.jpg",
content_type: "image/jpeg",
path: Path.absname("test/fixtures/image.jpg"),
tempfile: Path.absname("test/fixtures/image.jpg")
}
assert {:ok, :filtered, %{width: 266, height: 67}} = AnalyzeMetadata.filter(upload)
end
end