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, uploader: Mobilizon.Web.Upload.Uploader.Local,
filters: [ filters: [
Mobilizon.Web.Upload.Filter.Dedupe, Mobilizon.Web.Upload.Filter.Dedupe,
Mobilizon.Web.Upload.Filter.AnalyzeMetadata,
Mobilizon.Web.Upload.Filter.Optimize Mobilizon.Web.Upload.Filter.Optimize
], ],
allow_list_mime_types: ["image/gif", "image/jpeg", "image/png", "image/webp"], 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/starter-kit": "^2.0.0-beta.37",
"@tiptap/vue-2": "^2.0.0-beta.21", "@tiptap/vue-2": "^2.0.0-beta.21",
"apollo-absinthe-upload-link": "^1.5.0", "apollo-absinthe-upload-link": "^1.5.0",
"blurhash": "^1.1.3",
"buefy": "^0.9.0", "buefy": "^0.9.0",
"bulma-divider": "^0.2.0", "bulma-divider": "^0.2.0",
"core-js": "^3.6.4", "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 { IComment } from "@/types/comment.model";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import { IActivity } from "@/types/activity.model"; import { IActivity } from "@/types/activity.model";
import uniqBy from "lodash/uniqBy";
type possibleTypes = { name: string }; type possibleTypes = { name: string };
type schemaType = { type schemaType = {
@ -58,7 +59,7 @@ export const typePolicies: TypePolicies = {
Event: { Event: {
fields: { fields: {
participants: paginatedLimitPagination<IParticipant>(["roles"]), participants: paginatedLimitPagination<IParticipant>(["roles"]),
commnents: pageLimitPagination<IComment>(), comments: pageLimitPagination<IComment>(),
relatedEvents: pageLimitPagination<IEvent>(), relatedEvents: pageLimitPagination<IEvent>(),
}, },
}, },
@ -124,10 +125,6 @@ export function pageLimitPagination<T = Reference>(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
merge(existing, incoming, { args }) { merge(existing, incoming, { args }) {
console.log("pageLimitPagination");
console.log("existing", existing);
console.log("incoming", incoming);
// console.log("args", args);
if (!incoming) return existing; if (!incoming) return existing;
if (!existing) return incoming; // existing will be empty the first time 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 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
merge(existing, incoming, { args }) { merge(existing, incoming, { args }) {
console.log("paginatedLimitPagination");
console.log("existing", existing);
console.log("incoming", incoming);
if (!incoming) return existing; if (!incoming) return existing;
if (!existing) return incoming; // existing will be empty the first time if (!existing) return incoming; // existing will be empty the first time
@ -168,7 +162,6 @@ function doMerge<T = any>(
if (args) { if (args) {
// Assume an page of 1 if args.page omitted. // Assume an page of 1 if args.page omitted.
const { page = 1, limit = 10 } = args; const { page = 1, limit = 10 } = args;
console.log("args, selected", { page, limit });
for (let i = 0; i < incoming.length; ++i) { for (let i = 0; i < incoming.length; ++i) {
merged[(page - 1) * limit + i] = incoming[i]; merged[(page - 1) * limit + i] = incoming[i];
} }
@ -179,7 +172,8 @@ function doMerge<T = any>(
// exception here, instead of recovering by appending incoming // exception here, instead of recovering by appending incoming
// onto the existing array. // onto the existing array.
res = [...merged, ...incoming]; res = [...merged, ...incoming];
// eslint-disable-next-line no-underscore-dangle
res = uniqBy(res, (elem: any) => elem.__ref);
} }
console.log("doMerge returns", res);
return res; return res;
} }

View File

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

View File

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

View File

@ -74,7 +74,7 @@
@delete-comment="deleteComment" @delete-comment="deleteComment"
/> />
</transition-group> </transition-group>
<div class="no-comments" key="no-comments"> <div v-else class="no-comments" key="no-comments">
<span>{{ $t("No comments yet") }}</span> <span>{{ $t("No comments yet") }}</span>
</div> </div>
</transition-group> </transition-group>
@ -311,7 +311,18 @@ export default class CommentTree extends Vue {
return this.comments return this.comments
.filter((comment) => comment.inReplyToComment == null) .filter((comment) => comment.inReplyToComment == null)
.sort((a, b) => { .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 ( return (
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );

View File

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

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,11 @@
class="event-minimalist-card-wrapper" class="event-minimalist-card-wrapper"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }" :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"> <div class="title-info-wrapper">
<p class="event-minimalist-title">{{ event.title }}</p> <p class="event-minimalist-title">{{ event.title }}</p>
<p v-if="event.physicalAddress" class="has-text-grey"> <p v-if="event.physicalAddress" class="has-text-grey">

View File

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

View File

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

View File

@ -115,12 +115,13 @@ footer.footer {
flex: 1; flex: 1;
max-width: 40rem; max-width: 40rem;
@include mobile { @include mobile {
max-width: 400px; max-width: 100%;
} }
} }
div.content { div.content {
flex: 1; flex: 1;
padding-top: 10px;
} }
ul { ul {
@ -131,6 +132,7 @@ footer.footer {
li { li {
display: inline-flex; display: inline-flex;
margin: auto 5px; margin: auto 5px;
padding: 2px 0;
a { a {
font-size: 1.1rem; font-size: 1.1rem;
} }
@ -143,9 +145,12 @@ footer.footer {
text-decoration-color: $secondary; text-decoration-color: $secondary;
} }
::v-deep span.select select { ::v-deep span.select {
background: $background-color; select,
color: $white; option {
background: $background-color;
color: $white;
}
} }
} }
</style> </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"; import gql from "graphql-tag";
const $addressFragment = ` export const ADDRESS_FRAGMENT = gql`
id, fragment AdressFragment on Address {
description, id
geom, description
street, geom
locality, street
postalCode, locality
region, postalCode
country, region
type, country
url, type
originId url
originId
}
`; `;
export const ADDRESS = gql` export const ADDRESS = gql`
query($query:String!, $locale: String, $type: AddressSearchType) { query ($query: String!, $locale: String, $type: AddressSearchType) {
searchAddress( searchAddress(query: $query, locale: $locale, type: $type) {
query: $query, ...AdressFragment
locale: $locale,
type: $type
) {
${$addressFragment}
}
} }
}
${ADDRESS_FRAGMENT}
`; `;
export const REVERSE_GEOCODE = gql` export const REVERSE_GEOCODE = gql`
query($latitude: Float!, $longitude: Float!, $zoom: Int, $locale: String) { query ($latitude: Float!, $longitude: Float!, $zoom: Int, $locale: String) {
reverseGeocode(latitude: $latitude, longitude: $longitude, zoom: $zoom, locale: $locale) { reverseGeocode(
${$addressFragment} latitude: $latitude
} longitude: $longitude
zoom: $zoom
locale: $locale
) {
...AdressFragment
} }
}
${ADDRESS_FRAGMENT}
`; `;

View File

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

View File

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

View File

@ -3,6 +3,7 @@ export interface IMedia {
url: string; url: string;
name: string; name: string;
alt: string; alt: string;
metadata: IMediaMetadata;
} }
export interface IMediaUpload { export interface IMediaUpload {
@ -10,3 +11,9 @@ export interface IMediaUpload {
name: string; name: string;
alt: string | null; 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: $primary;
$link-invert: $primary-invert; $link-invert: $primary-invert;
$text: $violet-1; $text: $violet-1;
$grey: #757575;
$colors: map-merge( $colors: map-merge(
$colors, $colors,

View File

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

View File

@ -1,299 +1,296 @@
<template> <template>
<div class="container"> <div class="container">
<transition appear name="fade" mode="out-in"> <transition appear name="fade" mode="out-in">
<div> <div class="wrapper">
<div <event-banner :picture="event.picture" />
class="header-picture" <div class="intro-wrapper">
v-if="event.picture" <div class="date-calendar-icon-wrapper">
:style="`background-image: url('${event.picture.url}')`" <date-calendar-icon :date="event.beginsOn" />
/> </div>
<div class="header-picture-default" v-else /> <section class="intro">
<section class="section intro"> <div class="columns">
<div class="columns"> <div class="column">
<div class="column is-1-tablet"> <h1 class="title" style="margin: 0">{{ event.title }}</h1>
<date-calendar-icon :date="event.beginsOn" /> <div class="organizer">
</div> <span v-if="event.organizerActor && !event.attributedTo">
<div class="column"> <popover-actor-card
<h1 class="title" style="margin: 0">{{ event.title }}</h1> :actor="event.organizerActor"
<div class="organizer"> :inline="true"
<span v-if="event.organizerActor && !event.attributedTo"> >
<popover-actor-card <span>
:actor="event.organizerActor" {{
:inline="true" $t("By @{username}", {
username: usernameWithDomain(event.organizerActor),
})
}}
</span>
</popover-actor-card>
</span>
<span
v-else-if="
event.attributedTo &&
event.options.hideOrganizerWhenGroupEvent
"
> >
<span>
{{
$t("By @{username}", {
username: usernameWithDomain(event.organizerActor),
})
}}
</span>
</popover-actor-card>
</span>
<span
v-else-if="
event.attributedTo &&
event.options.hideOrganizerWhenGroupEvent
"
>
<popover-actor-card
:actor="event.attributedTo"
:inline="true"
>
{{
$t("By @{group}", {
group: usernameWithDomain(event.attributedTo),
})
}}
</popover-actor-card>
</span>
<span v-else-if="event.organizerActor && event.attributedTo">
<i18n path="By {group}">
<popover-actor-card <popover-actor-card
:actor="event.attributedTo" :actor="event.attributedTo"
slot="group"
:inline="true" :inline="true"
>
{{
$t("By @{group}", {
group: usernameWithDomain(event.attributedTo),
})
}}
</popover-actor-card>
</span>
<span v-else-if="event.organizerActor && event.attributedTo">
<i18n path="By {group}">
<popover-actor-card
:actor="event.attributedTo"
slot="group"
:inline="true"
>
<router-link
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(
event.attributedTo
),
},
}"
>
{{
$t("@{group}", {
group: usernameWithDomain(event.attributedTo),
})
}}
</router-link>
</popover-actor-card>
</i18n>
</span>
</div>
<p class="tags" v-if="event.tags && event.tags.length > 0">
<router-link
v-for="tag in event.tags"
:key="tag.title"
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
>
<tag>{{ tag.title }}</tag>
</router-link>
</p>
<b-tag type="is-warning" size="is-medium" v-if="event.draft"
>{{ $t("Draft") }}
</b-tag>
<span
class="event-status"
v-if="event.status !== EventStatus.CONFIRMED"
>
<b-tag
type="is-warning"
v-if="event.status === EventStatus.TENTATIVE"
>{{ $t("Event to be confirmed") }}</b-tag
>
<b-tag
type="is-danger"
v-if="event.status === EventStatus.CANCELLED"
>{{ $t("Event cancelled") }}</b-tag
>
</span>
</div>
<div class="column is-3-tablet">
<participation-section
:participation="participations[0]"
:event="event"
:anonymousParticipation="anonymousParticipation"
@join-event="joinEvent"
@join-modal="isJoinModalActive = true"
@join-event-with-confirmation="joinEventWithConfirmation"
@confirm-leave="confirmLeave"
@cancel-anonymous-participation="cancelAnonymousParticipation"
/>
<div class="has-text-right">
<template class="visibility" v-if="!event.draft">
<p v-if="event.visibility === EventVisibility.PUBLIC">
{{ $t("Public event") }}
<b-icon icon="earth" />
</p>
<p v-if="event.visibility === EventVisibility.UNLISTED">
{{ $t("Private event") }}
<b-icon icon="link" />
</p>
</template>
<template v-if="!event.local && organizer">
<a :href="event.url">
<tag>{{ organizer.domain }}</tag>
</a>
</template>
<p>
<router-link
class="participations-link"
v-if="actorIsOrganizer && event.draft === false"
:to="{
name: RouteName.PARTICIPATIONS,
params: { eventId: event.uuid },
}"
>
<!-- We retire one because of the event creator who is a participant -->
<span v-if="event.options.maximumAttendeeCapacity">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc(
"No one is participating|One person participating|{going} people participating",
event.participantStats.participant,
{
going: event.participantStats.participant,
}
)
}}
</span>
</router-link>
<span v-else>
<span v-if="event.options.maximumAttendeeCapacity">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc(
"No one is participating|One person participating|{going} people participating",
event.participantStats.participant,
{
going: event.participantStats.participant,
}
)
}}
</span>
</span>
<b-tooltip
type="is-dark"
v-if="!event.local"
:label="
$t(
'The actual number of participants may differ, as this event is hosted on another instance.'
)
"
>
<b-icon size="is-small" icon="help-circle-outline" />
</b-tooltip>
<b-icon icon="ticket-confirmation-outline" />
</p>
<b-dropdown position="is-bottom-left" aria-role="list">
<b-button
slot="trigger"
role="button"
icon-right="dots-horizontal"
>
{{ $t("Actions") }}
</b-button>
<b-dropdown-item
aria-role="listitem"
has-link
v-if="actorIsOrganizer || event.draft"
> >
<router-link <router-link
:to="{ :to="{
name: RouteName.GROUP, name: RouteName.EDIT_EVENT,
params: { params: { eventId: event.uuid },
preferredUsername: usernameWithDomain(
event.attributedTo
),
},
}" }"
> >
{{ {{ $t("Edit") }}
$t("@{group}", { <b-icon icon="pencil" />
group: usernameWithDomain(event.attributedTo),
})
}}
</router-link> </router-link>
</popover-actor-card> </b-dropdown-item>
</i18n> <b-dropdown-item
</span> aria-role="listitem"
</div> has-link
<p class="tags" v-if="event.tags && event.tags.length > 0"> v-if="actorIsOrganizer || event.draft"
<router-link
v-for="tag in event.tags"
:key="tag.title"
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
>
<tag>{{ tag.title }}</tag>
</router-link>
</p>
<b-tag type="is-warning" size="is-medium" v-if="event.draft"
>{{ $t("Draft") }}
</b-tag>
<span
class="event-status"
v-if="event.status !== EventStatus.CONFIRMED"
>
<b-tag
type="is-warning"
v-if="event.status === EventStatus.TENTATIVE"
>{{ $t("Event to be confirmed") }}</b-tag
>
<b-tag
type="is-danger"
v-if="event.status === EventStatus.CANCELLED"
>{{ $t("Event cancelled") }}</b-tag
>
</span>
</div>
<div class="column is-3-tablet">
<participation-section
:participation="participations[0]"
:event="event"
:anonymousParticipation="anonymousParticipation"
@join-event="joinEvent"
@join-modal="isJoinModalActive = true"
@join-event-with-confirmation="joinEventWithConfirmation"
@confirm-leave="confirmLeave"
@cancel-anonymous-participation="cancelAnonymousParticipation"
/>
<div class="has-text-right">
<template class="visibility" v-if="!event.draft">
<p v-if="event.visibility === EventVisibility.PUBLIC">
{{ $t("Public event") }}
<b-icon icon="earth" />
</p>
<p v-if="event.visibility === EventVisibility.UNLISTED">
{{ $t("Private event") }}
<b-icon icon="link" />
</p>
</template>
<template v-if="!event.local && organizer">
<a :href="event.url">
<tag>{{ organizer.domain }}</tag>
</a>
</template>
<p>
<router-link
class="participations-link"
v-if="actorIsOrganizer && event.draft === false"
:to="{
name: RouteName.PARTICIPATIONS,
params: { eventId: event.uuid },
}"
>
<!-- We retire one because of the event creator who is a participant -->
<span v-if="event.options.maximumAttendeeCapacity">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc(
"No one is participating|One person participating|{going} people participating",
event.participantStats.participant,
{
going: event.participantStats.participant,
}
)
}}
</span>
</router-link>
<span v-else>
<span v-if="event.options.maximumAttendeeCapacity">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc(
"No one is participating|One person participating|{going} people participating",
event.participantStats.participant,
{
going: event.participantStats.participant,
}
)
}}
</span>
</span>
<b-tooltip
type="is-dark"
v-if="!event.local"
:label="
$t(
'The actual number of participants may differ, as this event is hosted on another instance.'
)
"
>
<b-icon size="is-small" icon="help-circle-outline" />
</b-tooltip>
<b-icon icon="ticket-confirmation-outline" />
</p>
<b-dropdown position="is-bottom-left" aria-role="list">
<b-button
slot="trigger"
role="button"
icon-right="dots-horizontal"
>
{{ $t("Actions") }}
</b-button>
<b-dropdown-item
aria-role="listitem"
has-link
v-if="actorIsOrganizer || event.draft"
>
<router-link
:to="{
name: RouteName.EDIT_EVENT,
params: { eventId: event.uuid },
}"
> >
{{ $t("Edit") }} <router-link
<b-icon icon="pencil" /> :to="{
</router-link> name: RouteName.DUPLICATE_EVENT,
</b-dropdown-item> params: { eventId: event.uuid },
<b-dropdown-item }"
aria-role="listitem" >
has-link {{ $t("Duplicate") }}
v-if="actorIsOrganizer || event.draft" <b-icon icon="content-duplicate" />
> </router-link>
<router-link </b-dropdown-item>
:to="{ <b-dropdown-item
name: RouteName.DUPLICATE_EVENT, aria-role="listitem"
params: { eventId: event.uuid }, v-if="actorIsOrganizer || event.draft"
}" @click="openDeleteEventModalWrapper"
> >
{{ $t("Duplicate") }} {{ $t("Delete") }}
<b-icon icon="content-duplicate" /> <b-icon icon="delete" />
</router-link> </b-dropdown-item>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="actorIsOrganizer || event.draft"
@click="openDeleteEventModalWrapper"
>
{{ $t("Delete") }}
<b-icon icon="delete" />
</b-dropdown-item>
<hr <hr
class="dropdown-divider" class="dropdown-divider"
aria-role="menuitem" aria-role="menuitem"
v-if="actorIsOrganizer || event.draft" v-if="actorIsOrganizer || event.draft"
/> />
<b-dropdown-item <b-dropdown-item
aria-role="listitem" aria-role="listitem"
v-if="!event.draft" v-if="!event.draft"
@click="triggerShare()" @click="triggerShare()"
> >
<span> <span>
{{ $t("Share this event") }} {{ $t("Share this event") }}
<b-icon icon="share" /> <b-icon icon="share" />
</span> </span>
</b-dropdown-item> </b-dropdown-item>
<b-dropdown-item <b-dropdown-item
aria-role="listitem" aria-role="listitem"
@click="downloadIcsEvent()" @click="downloadIcsEvent()"
v-if="!event.draft" v-if="!event.draft"
> >
<span> <span>
{{ $t("Add to my calendar") }} {{ $t("Add to my calendar") }}
<b-icon icon="calendar-plus" /> <b-icon icon="calendar-plus" />
</span> </span>
</b-dropdown-item> </b-dropdown-item>
<b-dropdown-item <b-dropdown-item
aria-role="listitem" aria-role="listitem"
v-if="ableToReport" v-if="ableToReport"
@click="isReportModalActive = true" @click="isReportModalActive = true"
> >
<span> <span>
{{ $t("Report") }} {{ $t("Report") }}
<b-icon icon="flag" /> <b-icon icon="flag" />
</span> </span>
</b-dropdown-item> </b-dropdown-item>
</b-dropdown> </b-dropdown>
</div>
</div> </div>
</div> </div>
</div> </section>
</section> </div>
<div class="event-description-wrapper"> <div class="event-description-wrapper">
<aside class="event-metadata"> <aside class="event-metadata">
<div class="sticky"> <div class="sticky">
@ -662,6 +659,7 @@ import { IConfig } from "../../types/config.model";
import Subtitle from "../../components/Utils/Subtitle.vue"; import Subtitle from "../../components/Utils/Subtitle.vue";
import Tag from "../../components/Tag.vue"; import Tag from "../../components/Tag.vue";
import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue"; import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue";
import EventBanner from "../../components/Event/EventBanner.vue";
import ActorCard from "../../components/Account/ActorCard.vue"; import ActorCard from "../../components/Account/ActorCard.vue";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue"; import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
import { IParticipant } from "../../types/participant.model"; import { IParticipant } from "../../types/participant.model";
@ -683,6 +681,7 @@ import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
Tag, Tag,
ActorCard, ActorCard,
PopoverActorCard, PopoverActorCard,
EventBanner,
"map-leaflet": () => "map-leaflet": () =>
import(/* webpackChunkName: "map" */ "../../components/Map.vue"), import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
ShareEventModal: () => ShareEventModal: () =>
@ -1308,18 +1307,6 @@ export default class Event extends EventMixin {
opacity: 0; 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 { div.sidebar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1353,7 +1340,7 @@ div.sidebar {
} }
} }
.intro.section { .intro {
background: white; background: white;
.is-3-tablet { .is-3-tablet {
@ -1570,4 +1557,30 @@ a.participations-link {
border: 0; border: 0;
cursor: auto; 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> </style>

View File

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

View File

@ -15,6 +15,7 @@ import { CommentModeration } from "@/types/enums";
import { IEvent } from "@/types/event.model"; import { IEvent } from "@/types/event.model";
import { import {
eventCommentThreadsMock, eventCommentThreadsMock,
eventNoCommentThreadsMock,
newCommentForEventMock, newCommentForEventMock,
newCommentForEventResponse, newCommentForEventResponse,
} from "../../mocks/event"; } from "../../mocks/event";
@ -35,7 +36,7 @@ const eventData = {
}; };
describe("CommentTree", () => { describe("CommentTree", () => {
let wrapper: Wrapper<Vue>; let wrapper: Wrapper<Vue>;
let mockClient: MockApolloClient; let mockClient: MockApolloClient | null;
let apolloProvider; let apolloProvider;
let requestHandlers: Record<string, RequestHandler>; let requestHandlers: Record<string, RequestHandler>;
const cache = new InMemoryCache({ addTypename: false }); 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 () => { it("renders a comment tree with comments", async () => {
generateWrapper(); generateWrapper();
await wrapper.vm.$nextTick(); await flushPromises();
await wrapper.vm.$nextTick(); // because of the <transition>
expect(wrapper.exists()).toBe(true); expect(wrapper.exists()).toBe(true);
expect( 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`] = ` exports[`CommentTree renders a comment tree with comments 1`] = `
<div> <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 name="comment-empty-list" mode="out-in">
<transition-group-stub tag="ul" name="comment-list" class="comment-list"> <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>
<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> </transition-group-stub>
<div class="no-comments"><span>No comments yet</span></div>
</transition-group-stub> </transition-group-stub>
</div> </div>
`; `;
exports[`CommentTree renders an empty comment tree 1`] = ` exports[`CommentTree renders an empty comment tree 1`] = `
<div> <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 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> <div class="no-comments"><span>No comments yet</span></div>
</transition-group-stub> </transition-group-stub>
</div> </div>

View File

@ -63,6 +63,17 @@ export const joinEventMock = {
locale: "en_US", locale: "en_US",
}; };
export const eventNoCommentThreadsMock = {
data: {
event: {
__typename: "Event",
id: "1",
uuid: "f37910ea-fd5a-4756-9679-00971f3f4106",
comments: [],
},
},
};
export const eventCommentThreadsMock = { export const eventCommentThreadsMock = {
data: { data: {
event: { 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" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== 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: body-parser@1.19.0:
version "1.19.0" version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" 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 end
def make_media_data(media) when is_map(media) do 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), Mobilizon.Web.Upload.store(media.file),
{:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)}, {:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)},
{:ok, %Media{file: _file} = media} <- {:ok, %Media{file: _file} = media} <-
Mobilizon.Medias.create_media(%{ Mobilizon.Medias.create_media(%{
"file" => %{ file: Map.take(uploaded, [:url, :name, :content_type, :size]),
"url" => url, metadata: Map.take(uploaded, [:width, :height, :blurhash]),
"name" => media.name, actor_id: media.actor_id
"content_type" => content_type,
"size" => size
},
"actor_id" => media.actor_id
}) do }) do
Converter.Media.model_to_as(media) Converter.Media.model_to_as(media)
else else

View File

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

View File

@ -40,17 +40,13 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Media do
) )
when is_binary(media_url) do when is_binary(media_url) do
with {:ok, %{body: body}} <- Tesla.get(media_url, opts: @http_options), 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}), Upload.store(%{body: body, name: name}),
{:media_exists, nil} <- {:media_exists, Medias.get_media_by_url(url)} do {:media_exists, nil} <- {:media_exists, Medias.get_media_by_url(url)} do
Medias.create_media(%{ Medias.create_media(%{
"file" => %{ file: Map.take(uploaded, [:url, :name, :content_type, :size]),
"url" => url, metadata: Map.take(uploaded, [:width, :height, :blurhash]),
"name" => name, actor_id: actor_id
"content_type" => content_type,
"size" => size
},
"actor_id" => actor_id
}) })
else else
{:media_exists, %MediaModel{file: _file} = media} -> {: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() @spec find_webfinger_endpoint(String.t()) :: String.t()
def find_webfinger_endpoint(domain) when is_binary(domain) do def find_webfinger_endpoint(domain) when is_binary(domain) do
with {:ok, %{body: body}} <- fetch_document("http://#{domain}/.well-known/host-meta"), 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} {:ok, link_template}
end end
end end
@ -203,6 +203,9 @@ defmodule Mobilizon.Federation.WebFinger do
xpath(doc, ~x"//Link[@rel=\"lrdd\"][@type=\"application/json\"]/@template"s), xpath(doc, ~x"//Link[@rel=\"lrdd\"][@type=\"application/json\"]/@template"s),
res when res in [nil, ""] <- xpath(doc, ~x"//Link[@rel=\"lrdd\"]/@template"s), res when res in [nil, ""] <- xpath(doc, ~x"//Link[@rel=\"lrdd\"]/@template"s),
do: {:error, :link_not_found} do: {:error, :link_not_found}
catch
:exit, _e ->
{:error, :link_not_found}
end end
@spec fetch_document(String.t()) :: Tesla.Env.result() @spec fetch_document(String.t()) :: Tesla.Env.result()

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ defmodule Mobilizon.GraphQL.Schema.MediaType do
field(:url, :string, description: "The media's full URL") field(:url, :string, description: "The media's full URL")
field(:content_type, :string, description: "The media's detected content type") field(:content_type, :string, description: "The media's detected content type")
field(:size, :integer, description: "The media's size") field(:size, :integer, description: "The media's size")
field(:metadata, :media_metadata, description: "The media's metadata")
end end
@desc """ @desc """
@ -24,6 +25,15 @@ defmodule Mobilizon.GraphQL.Schema.MediaType do
field(:total, :integer, description: "The total number of medias in the list") field(:total, :integer, description: "The total number of medias in the list")
end 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" @desc "An attached media or a link to a media"
input_object :media_input do input_object :media_input do
# Either a full media object # 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 # However, it also excludes all top-level comments with deleted replies from being selected
# |> where([_, r], is_nil(r.deleted_at)) # |> where([_, r], is_nil(r.deleted_at))
|> group_by([c], c.id) |> group_by([c], c.id)
|> order_by([c], desc: :is_announcement, asc: :published_at)
|> select([c, r], %{c | total_replies: count(r.id)}) |> select([c, r], %{c | total_replies: count(r.id)})
end end
def query(Comment, _) do def query(Comment, _) do
order_by(Comment, [c], asc: :published_at) order_by(Comment, [c], asc: :is_announcement, asc: :published_at)
end end
def query(queryable, _) do def query(queryable, _) do

View File

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

View File

@ -5,21 +5,32 @@ defmodule Mobilizon.Medias.Media do
use Ecto.Schema 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.Actors.Actor
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Medias.File alias Mobilizon.Medias.File
alias Mobilizon.Medias.Media.Metadata
alias Mobilizon.Posts.Post alias Mobilizon.Posts.Post
@type t :: %__MODULE__{ @type t :: %__MODULE__{
file: File.t(), file: File.t(),
metadata: Metadata.t(),
actor: Actor.t() actor: Actor.t()
} }
@metadata_attrs [:height, :width, :blurhash]
schema "medias" do schema "medias" do
embeds_one(:file, File, on_replace: :update) 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) belongs_to(:actor, Actor)
has_many(:event_picture, Event, foreign_key: :picture_id) has_many(:event_picture, Event, foreign_key: :picture_id)
many_to_many(:events, Event, join_through: "events_medias") many_to_many(:events, Event, join_through: "events_medias")
@ -36,5 +47,13 @@ defmodule Mobilizon.Medias.Media do
media media
|> cast(attrs, [:actor_id]) |> cast(attrs, [:actor_id])
|> cast_embed(:file) |> 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
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, upload} <- Filter.filter(opts.filters, upload),
{:ok, url_spec} <- Uploader.put_file(opts.uploader, upload) do {:ok, url_spec} <- Uploader.put_file(opts.uploader, upload) do
{:ok, {:ok,
%{ upload
name: Map.get(opts, :description) || upload.name, |> Map.put(:name, Map.get(opts, :description) || upload.name)
url: url_from_spec(upload, opts.base_url, url_spec), |> Map.put(:url, url_from_spec(upload, opts.base_url, url_spec))}
content_type: upload.content_type,
size: upload.size
}}
else else
{:error, error} -> {:error, error} ->
Logger.error( Logger.error(

View File

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