Expose personal tokened feeds

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-03-26 19:01:55 +01:00
parent 380d7c56a8
commit cde9f8873e
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
15 changed files with 363 additions and 26 deletions

View file

@ -14,7 +14,7 @@
:to="{ name: RouteName.PREFERENCES }"
/>
<SettingMenuItem
:title="this.$t('Email notifications')"
:title="this.$t('Notifications')"
:to="{ name: RouteName.NOTIFICATIONS }"
/>
</SettingMenuSection>

View file

@ -241,3 +241,17 @@ export const UPDATE_USER_LOCALE = gql`
}
}
`;
export const FEED_TOKENS_LOGGED_USER = gql`
query {
loggedUser {
id
feedTokens {
token
actor {
id
}
}
}
}
`;

View file

@ -969,5 +969,13 @@
"You replied to a comment on the event {event}.": "You replied to a comment on the event {event}.",
"{profile} replied to a comment on the event {event}.": "{profile} replied to a comment on the event {event}.",
"New post": "New post",
"Comment text can't be empty": "Comment text can't be empty"
"Comment text can't be empty": "Comment text can't be empty",
"Notifications": "Notifications",
"Profile feeds": "Profile feeds",
"These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.",
"Regenerate new links": "Regenerate new links",
"Create new links": "Create new links",
"You'll need to change the URLs where there were previously entered.": "You'll need to change the URLs where there were previously entered.",
"Personal feeds": "Personal feeds",
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
}

View file

@ -1063,5 +1063,13 @@
"You replied to a comment on the event {event}.": "Vous avez répondu à un commentaire sur l'événement {event}.",
"{profile} replied to a comment on the event {event}.": "{profile} a répondu à un commentaire sur l'événement {event}.",
"New post": "Nouveau billet",
"Comment text can't be empty": "Le texte du commentaire ne peut être vide"
"Comment text can't be empty": "Le texte du commentaire ne peut être vide",
"Notifications": "Notifications",
"Profile feeds": "Flux du profil",
"These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "Ces flux contiennent des informations sur les événements pour lesquels ce profil spécifique est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux pour l'ensemble de vos profils dans vos paramètres de notification.",
"Regenerate new links": "Regénérer de nouveaux liens",
"Create new links": "Créer de nouveaux liens",
"You'll need to change the URLs where there were previously entered.": "Vous devrez changer les URLs là où vous les avez entrées précédemment.",
"Personal feeds": "Flux personnels",
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils."
}

View file

@ -98,6 +98,77 @@
$t("Delete this identity")
}}</span>
</div>
<section v-if="isUpdate">
<div class="setting-title">
<h2>{{ $t("Profile feeds") }}</h2>
</div>
<p>
{{
$t(
"These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings."
)
}}
</p>
<div v-if="identity.feedTokens && identity.feedTokens.length > 0">
<div
class="buttons"
v-for="feedToken in identity.feedTokens"
:key="feedToken.token"
>
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.atom"
always
type="is-success"
position="is-left"
>
<b-button
tag="a"
icon-left="rss"
@click="
(e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
:href="tokenToURL(feedToken.token, 'atom')"
target="_blank"
>{{ $t("RSS/Atom Feed") }}</b-button
>
</b-tooltip>
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.ics"
always
type="is-success"
position="is-left"
>
<b-button
tag="a"
@click="
(e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
icon-left="calendar-sync"
:href="tokenToURL(feedToken.token, 'ics')"
target="_blank"
>{{ $t("ICS/WebCal Feed") }}</b-button
>
</b-tooltip>
<b-button
icon-left="refresh"
type="is-text"
@click="openRegenerateFeedTokensConfirmation"
>{{ $t("Regenerate new links") }}</b-button
>
</div>
</div>
<div v-else>
<b-button
icon-left="refresh"
type="is-text"
@click="generateFeedTokens"
>{{ $t("Create new links") }}</b-button
>
</div>
</section>
</div>
</div>
</template>
@ -131,6 +202,10 @@ h1 {
.username-field + .field {
margin-bottom: 0;
}
::v-deep .buttons > *:not(:last-child) .button {
margin-right: 0.5rem;
}
</style>
<script lang="ts">
@ -151,6 +226,11 @@ import RouteName from "../../../router/name";
import { buildFileVariable } from "../../../utils/image";
import { changeIdentity } from "../../../utils/auth";
import identityEditionMixin from "../../../mixins/identityEdition";
import {
CREATE_FEED_TOKEN_ACTOR,
DELETE_FEED_TOKEN,
} from "@/graphql/feed_tokens";
import { IFeedToken } from "@/types/feedtoken.model";
@Component({
components: {
@ -191,6 +271,8 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
RouteName = RouteName;
showCopiedTooltip = { ics: false, atom: false };
get message(): string | null {
if (this.isUpdate) return null;
return this.$t(
@ -353,6 +435,63 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
return MOBILIZON_INSTANCE_HOST;
}
tokenToURL(token: string, format: string): string {
return `${window.location.origin}/events/going/${token}/${format}`;
}
copyURL(e: Event, url: string, format: "ics" | "atom"): void {
if (navigator.clipboard) {
e.preventDefault();
navigator.clipboard.writeText(url);
this.showCopiedTooltip[format] = true;
setTimeout(() => {
this.showCopiedTooltip[format] = false;
}, 2000);
}
}
async generateFeedTokens(): Promise<void> {
const newToken = await this.createNewFeedToken();
this.identity.feedTokens.push(newToken);
}
async regenerateFeedTokens(): Promise<void> {
if (this.identity?.feedTokens.length < 1) return;
await this.deleteFeedToken(this.identity.feedTokens[0].token);
const newToken = await this.createNewFeedToken();
this.identity.feedTokens.pop();
this.identity.feedTokens.push(newToken);
}
private async deleteFeedToken(token: string): Promise<void> {
await this.$apollo.mutate({
mutation: DELETE_FEED_TOKEN,
variables: { token },
});
}
private async createNewFeedToken(): Promise<IFeedToken> {
const { data } = await this.$apollo.mutate({
mutation: CREATE_FEED_TOKEN_ACTOR,
variables: { actor_id: this.identity?.id },
});
return data.createFeedToken;
}
openRegenerateFeedTokensConfirmation(): void {
this.$buefy.dialog.confirm({
type: "is-warning",
title: this.$t("Regenerate new links") as string,
message: this.$t(
"You'll need to change the URLs where there were previously entered."
) as string,
confirmText: this.$t("Regenerate new links") as string,
cancelText: this.$t("Cancel") as string,
onConfirm: () => this.regenerateFeedTokens(),
});
}
openDeleteIdentityConfirmation(): void {
this.$buefy.dialog.prompt({
type: "is-danger",

View file

@ -9,7 +9,7 @@
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.NOTIFICATIONS }">{{
$t("Email notifications")
$t("Notifications")
}}</router-link>
</li>
</ul>
@ -118,23 +118,108 @@
</b-select>
</div>
</section>
<section>
<div class="setting-title">
<h2>{{ $t("Personal feeds") }}</h2>
</div>
<p>
{{
$t(
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
)
}}
</p>
<div v-if="feedTokens && feedTokens.length > 0">
<div
class="buttons"
v-for="feedToken in feedTokens"
:key="feedToken.token"
>
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.atom"
always
type="is-success"
position="is-left"
>
<b-button
tag="a"
icon-left="rss"
@click="
(e) => copyURL(e, tokenToURL(feedToken.token, 'atom'), 'atom')
"
:href="tokenToURL(feedToken.token, 'atom')"
target="_blank"
>{{ $t("RSS/Atom Feed") }}</b-button
>
</b-tooltip>
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip.ics"
always
type="is-success"
position="is-left"
>
<b-button
tag="a"
@click="
(e) => copyURL(e, tokenToURL(feedToken.token, 'ics'), 'ics')
"
icon-left="calendar-sync"
:href="tokenToURL(feedToken.token, 'ics')"
target="_blank"
>{{ $t("ICS/WebCal Feed") }}</b-button
>
</b-tooltip>
<b-button
icon-left="refresh"
type="is-text"
@click="openRegenerateFeedTokensConfirmation"
>{{ $t("Regenerate new links") }}</b-button
>
</div>
</div>
<div v-else>
<b-button
icon-left="refresh"
type="is-text"
@click="generateFeedTokens"
>{{ $t("Create new links") }}</b-button
>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { INotificationPendingEnum } from "@/types/enums";
import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
import {
USER_SETTINGS,
SET_USER_SETTINGS,
FEED_TOKENS_LOGGED_USER,
} from "../../graphql/user";
import { IUser } from "../../types/current-user.model";
import RouteName from "../../router/name";
import { IFeedToken } from "@/types/feedtoken.model";
import { CREATE_FEED_TOKEN, DELETE_FEED_TOKEN } from "@/graphql/feed_tokens";
@Component({
apollo: {
loggedUser: USER_SETTINGS,
feedTokens: {
query: FEED_TOKENS_LOGGED_USER,
update: (data) =>
data.loggedUser.feedTokens.filter(
(token: IFeedToken) => token.actor === null
),
},
},
})
export default class Notifications extends Vue {
loggedUser!: IUser;
feedTokens: IFeedToken[] = [];
notificationOnDay: boolean | undefined = true;
notificationEachWeek: boolean | undefined = false;
@ -148,6 +233,8 @@ export default class Notifications extends Vue {
RouteName = RouteName;
showCopiedTooltip = { ics: false, atom: false };
mounted(): void {
this.notificationPendingParticipationValues = {
[INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"),
@ -176,6 +263,62 @@ export default class Notifications extends Vue {
refetchQueries: [{ query: USER_SETTINGS }],
});
}
tokenToURL(token: string, format: string): string {
return `${window.location.origin}/events/going/${token}/${format}`;
}
copyURL(e: Event, url: string, format: "ics" | "atom"): void {
if (navigator.clipboard) {
e.preventDefault();
navigator.clipboard.writeText(url);
this.showCopiedTooltip[format] = true;
setTimeout(() => {
this.showCopiedTooltip[format] = false;
}, 2000);
}
}
openRegenerateFeedTokensConfirmation(): void {
this.$buefy.dialog.confirm({
type: "is-warning",
title: this.$t("Regenerate new links") as string,
message: this.$t(
"You'll need to change the URLs where there were previously entered."
) as string,
confirmText: this.$t("Regenerate new links") as string,
cancelText: this.$t("Cancel") as string,
onConfirm: () => this.regenerateFeedTokens(),
});
}
async regenerateFeedTokens(): Promise<void> {
if (this.feedTokens.length < 1) return;
await this.deleteFeedToken(this.feedTokens[0].token);
const newToken = await this.createNewFeedToken();
this.feedTokens.pop();
this.feedTokens.push(newToken);
}
async generateFeedTokens(): Promise<void> {
const newToken = await this.createNewFeedToken();
this.feedTokens.push(newToken);
}
private async deleteFeedToken(token: string): Promise<void> {
await this.$apollo.mutate({
mutation: DELETE_FEED_TOKEN,
variables: { token },
});
}
private async createNewFeedToken(): Promise<IFeedToken> {
const { data } = await this.$apollo.mutate({
mutation: CREATE_FEED_TOKEN,
});
return data.createFeedToken;
}
}
</script>
@ -193,4 +336,8 @@ export default class Notifications extends Vue {
margin-left: 5px;
}
}
::v-deep .buttons > *:not(:last-child) .button {
margin-right: 0.5rem;
}
</style>

View file

@ -22,7 +22,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
) do
with {:is_owned, %Actor{}} <- User.owns_actor(user, actor_id),
{:ok, feed_token} <- Events.create_feed_token(%{user_id: id, actor_id: actor_id}) do
{:ok, feed_token}
{:ok, to_short_uuid(feed_token)}
else
{:is_owned, nil} ->
{:error, dgettext("errors", "Profile is not owned by authenticated user")}
@ -32,7 +32,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
@spec create_feed_token(any, map, map) :: {:ok, FeedToken.t()}
def create_feed_token(_parent, %{}, %{context: %{current_user: %User{id: id}}}) do
with {:ok, feed_token} <- Events.create_feed_token(%{user_id: id}) do
{:ok, feed_token}
{:ok, to_short_uuid(feed_token)}
end
end
@ -50,7 +50,8 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
%{token: token},
%{context: %{current_user: %User{id: id} = _user}}
) do
with {:ok, token} <- Ecto.UUID.cast(token),
with {:ok, token} <- ShortUUID.decode(token),
{:ok, token} <- Ecto.UUID.cast(token),
{:no_token, %FeedToken{actor: actor, user: %User{} = user} = feed_token} <-
{:no_token, Events.get_feed_token(token)},
{:token_from_user, true} <- {:token_from_user, id == user.id},
@ -65,6 +66,9 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
:error ->
{:error, dgettext("errors", "Token is not a valid UUID")}
{:error, "Invalid input"} ->
{:error, dgettext("errors", "Token is not a valid UUID")}
{:no_token, _} ->
{:error, dgettext("errors", "Token does not exist")}
@ -77,4 +81,8 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedToken do
def delete_feed_token(_parent, _args, %{}) do
{:error, dgettext("errors", "You are not allowed to delete a feed token if not connected")}
end
defp to_short_uuid(%FeedToken{token: token} = feed_token) do
%FeedToken{feed_token | token: ShortUUID.encode!(token)}
end
end

View file

@ -4,7 +4,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
"""
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
import Absinthe.Resolution.Helpers, only: [dataloader: 2]
alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Media, Person}
@ -53,7 +53,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
)
field(:feed_tokens, list_of(:feed_token),
resolve: dataloader(Events),
resolve:
dataloader(
Events,
callback: fn feed_tokens, _parent, _args ->
{:ok, Enum.map(feed_tokens, &Map.put(&1, :token, ShortUUID.encode!(&1.token)))}
end
),
description: "A list of the feed tokens for this person"
)

View file

@ -31,7 +31,7 @@ defmodule Mobilizon.GraphQL.Schema.Events.FeedTokenType do
description: "The actor that participates to the event"
)
field(:token, :string, description: "The role of this actor at this event")
field(:token, :string, description: "A ShortUUID private token")
end
@desc "Represents a deleted feed_token"

View file

@ -4,7 +4,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
"""
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
import Absinthe.Resolution.Helpers, only: [dataloader: 2]
alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Media, User}
@ -43,7 +43,13 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
)
field(:feed_tokens, list_of(:feed_token),
resolve: dataloader(Events),
resolve:
dataloader(
Events,
callback: fn feed_tokens, _parent, _args ->
{:ok, Enum.map(feed_tokens, &Map.put(&1, :token, ShortUUID.encode!(&1.token)))}
end
),
description: "A list of the feed tokens for this user"
)

View file

@ -25,8 +25,9 @@ defmodule Mobilizon.Service.Export.Common do
# Only events, not posts
@spec fetch_events_from_token(String.t()) :: String.t()
def fetch_events_from_token(token) do
with {:ok, _uuid} <- Ecto.UUID.cast(token),
%FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do
with {:ok, uuid} <- ShortUUID.decode(token),
{:ok, _uuid} <- Ecto.UUID.cast(uuid),
%FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(uuid) do
case actor do
%Actor{} = actor ->
%{

View file

@ -26,7 +26,7 @@ defmodule Mobilizon.Web.FeedController do
end
def event(conn, %{"uuid" => uuid, "format" => "ics"}) do
return_data(conn, "ics", "event_" <> uuid, "event.ics")
return_data(conn, "ics", "event_" <> uuid, "event")
end
def event(_conn, _) do
@ -34,7 +34,7 @@ defmodule Mobilizon.Web.FeedController do
end
def going(conn, %{"token" => token, "format" => format}) when format in @formats do
return_data(conn, format, "token_" <> token, "events.#{format}")
return_data(conn, format, "token_" <> token, "events")
end
def going(_conn, _) do

View file

@ -186,7 +186,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
%{
"feedTokens" => [
%{
"token" => feed_token.token
"token" => ShortUUID.encode!(feed_token.token)
}
]
}
@ -194,7 +194,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
mutation = """
mutation {
deleteFeedToken(
token: "#{feed_token.token}",
token: "#{ShortUUID.encode!(feed_token.token)}",
) {
actor {
id
@ -270,7 +270,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
mutation = """
mutation {
deleteFeedToken(
token: "#{feed_token.token}",
token: "#{ShortUUID.encode!(feed_token.token)}",
) {
actor {
id
@ -320,7 +320,7 @@ defmodule Mobilizon.GraphQL.Resolvers.FeedTokenTest do
mutation = """
mutation {
deleteFeedToken(
token: "#{uuid}"
token: "#{ShortUUID.encode!(uuid)}"
) {
actor {
id

View file

@ -58,7 +58,7 @@ defmodule Mobilizon.Service.ICalendarTest do
event = insert(:event)
insert(:participant, event: event, actor: actor, role: :participant)
{:commit, ics} = ICalendarService.create_cache("token_#{token}")
{:commit, ics} = ICalendarService.create_cache("token_#{ShortUUID.encode!(token)}")
assert ics =~ event.title
end
end

View file

@ -225,7 +225,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
conn
|> get(
Endpoint
|> Routes.feed_url(:going, feed_token.token, "atom")
|> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "atom")
|> URI.decode()
)
@ -260,7 +260,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
|> put_req_header("accept", "application/atom+xml")
|> get(
Endpoint
|> Routes.feed_url(:going, feed_token.token, "atom")
|> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "atom")
|> URI.decode()
)
@ -307,7 +307,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
|> put_req_header("accept", "text/calendar")
|> get(
Endpoint
|> Routes.feed_url(:going, feed_token.token, "ics")
|> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "ics")
|> URI.decode()
)
@ -338,7 +338,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
|> put_req_header("accept", "text/calendar")
|> get(
Endpoint
|> Routes.feed_url(:going, feed_token.token, "ics")
|> Routes.feed_url(:going, ShortUUID.encode!(feed_token.token), "ics")
|> URI.decode()
)