mirror of
https://github.com/wassname/talk.git
synced 2026-08-13 12:40:11 +08:00
Merge branch 'master' into gdpr-delete
This commit is contained in:
@@ -6,6 +6,8 @@ import Login from './login/containers/Main';
|
||||
import reducer from './login/reducer';
|
||||
import DeleteMyAccount from './profile-settings/containers/DeleteMyAccount';
|
||||
import AccountDeletionRequestedSign from './stream/containers/AccountDeletionRequestedSign';
|
||||
import ChangePassword from './profile-settings/containers/ChangePassword';
|
||||
import ChangeUsername from './profile-settings/containers/ChangeUsername';
|
||||
|
||||
export default {
|
||||
reducer,
|
||||
@@ -18,6 +20,7 @@ export default {
|
||||
SetUsernameDialog,
|
||||
],
|
||||
login: [Login],
|
||||
profileSettings: [DeleteMyAccount],
|
||||
profileHeader: [ChangeUsername],
|
||||
profileSettings: [ChangePassword, DeleteMyAccount],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -103,8 +103,7 @@ class SignUp extends React.Component {
|
||||
/>
|
||||
{passwordError && (
|
||||
<span className={styles.hint}>
|
||||
{' '}
|
||||
Password must be at least 8 characters.{' '}
|
||||
{t('talk-plugin-auth.login.password_error')}
|
||||
</span>
|
||||
)}
|
||||
<TextField
|
||||
|
||||
@@ -58,8 +58,9 @@ da:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
password_error: "Password must be at least 8 characters."
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
register: "Register"
|
||||
@@ -101,8 +102,9 @@ en:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
password_error: "Password must be at least 8 characters."
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
register: "Register"
|
||||
@@ -131,6 +133,29 @@ en:
|
||||
username: Username
|
||||
write_your_username: "Edit your username"
|
||||
your_username: "Your username appears on every comment you post."
|
||||
change_password:
|
||||
change_password: "Change Password"
|
||||
passwords_dont_match: "Passwords don`t match"
|
||||
required_field: "This field is required"
|
||||
forgot_password: "Forgot your password?"
|
||||
save: "Save"
|
||||
cancel: "Cancel"
|
||||
edit: "Edit"
|
||||
changed_password_msg: "Changed Password - Your password has been successfully changed"
|
||||
change_username:
|
||||
change_username_note: "Usernames can be changed every 14 days"
|
||||
save: "Save"
|
||||
edit_profile: "Edit Profile"
|
||||
cancel: "Cancel"
|
||||
confirm_username_change: "Confirm Username Change"
|
||||
description: "You are attempting to change your username. Your new username will appear on all of your past and future comments."
|
||||
old_username: "Old Username"
|
||||
new_username: "New Username"
|
||||
bottom_note: "Note: You will not be able to change your username again for 14 days"
|
||||
confirm_changes: "Confirm Changes"
|
||||
username_does_not_match: "Username does not match"
|
||||
changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days."
|
||||
change_username_attempt: "Username can't be updated. Usernames can be changed every 14 days"
|
||||
de:
|
||||
talk-plugin-auth:
|
||||
login:
|
||||
@@ -192,6 +217,7 @@ es:
|
||||
or: "O"
|
||||
email: "Dirección de Correo"
|
||||
password: "Contraseña"
|
||||
password_error: "La contraseña debe tener al menos 8 caracteres."
|
||||
forgot_your_pass: "¿Has olvidado tu contraseña?"
|
||||
need_an_account: "¿Necesitas una cuenta?"
|
||||
register: "Registrar"
|
||||
@@ -222,6 +248,29 @@ es:
|
||||
username: Nombre
|
||||
write_your_username: "Edita tu nombre"
|
||||
your_username: "Tu nombre aparece en cada comentario que publiques."
|
||||
change_password:
|
||||
change_password: "Cambiar Contraseña"
|
||||
passwords_dont_match: "Las contraseñas no coinciden"
|
||||
required_field: "Este campo es requerido"
|
||||
forgot_password: "Olvidaste tu contraseña?"
|
||||
save: "Guardar"
|
||||
cancel: "Cancelar"
|
||||
edit: "Editar"
|
||||
changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada"
|
||||
change_username:
|
||||
change_username_note: "El usuario puede ser cambiado cada 14 días"
|
||||
save: "Guardar"
|
||||
edit_profile: "Editar Perfil"
|
||||
cancel: "Cancelar"
|
||||
confirm_username_change: "Confirmar Cambio de Usuario"
|
||||
description: "Estás intentando cambiar tu usuario. Tu nuevo usuario aparecerá en todos tus pasados y futuros comentarios."
|
||||
old_username: "Usuario viejo"
|
||||
new_username: "Usuario nuevo"
|
||||
bottom_note: "Nota: No podrás cambiar tu usuario por 14 días"
|
||||
confirm_changes: "Confirmar Cambios"
|
||||
username_does_not_match: "El usuario no coincide"
|
||||
changed_username_success_msg: "Usuario Actualizado - Tu usuario ha sido exitosamente actualizado. No podrás cambiar el usuario por 14 días."
|
||||
change_username_attempt: "El usuario no puede ser actualizado. Los usuarios pueden ser cambiados cada 14 días."
|
||||
fr:
|
||||
talk-plugin-auth:
|
||||
login:
|
||||
@@ -324,7 +373,7 @@ pt_BR:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<head>
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
|
||||
<title><%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %></title>
|
||||
<%- include(root + '/partials/head') %>
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
|
||||
<%- include(root + '/partials/head') %>
|
||||
</head>
|
||||
<body class="confirm-email-page">
|
||||
<div id="root">
|
||||
|
||||
@@ -20,4 +20,5 @@ their profile tab in the comment stream. Once clicked, an email will be sent
|
||||
that contains a download link. Only one link can be generated every 7 days, and
|
||||
the link will be valid for 24 hours.
|
||||
|
||||
The downloaded zip file will contain the users comments in a CSV format.
|
||||
The downloaded zip file will contain all the users comments in a CSV format
|
||||
including those that have been rejected, withheld, or still in premod.
|
||||
|
||||
@@ -1 +1 @@
|
||||
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= BASE_URL %>account/download#<%= token %>"><%= t('email.download.download_archive') %></a></p>
|
||||
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= downloadLandingURL %>"><%= t('email.download.download_archive') %></a></p>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %>
|
||||
|
||||
<%= BASE_URL %>account/download#<%= token %>
|
||||
<%= downloadLandingURL %>
|
||||
|
||||
@@ -6,18 +6,41 @@ const {
|
||||
ErrDeletionAlreadyScheduled,
|
||||
ErrDeletionNotScheduled,
|
||||
} = require('./errors');
|
||||
const { ErrNotAuthorized } = require('errors');
|
||||
const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors');
|
||||
const { URL } = require('url');
|
||||
|
||||
// generateDownloadLinks will generate a signed set of links for a given user to
|
||||
// download an archive of their data.
|
||||
async function generateDownloadLinks(ctx, userID) {
|
||||
const { connectors: { url: { BASE_URL }, secrets } } = ctx;
|
||||
|
||||
// Generate a token for the download link.
|
||||
const token = await secrets.jwt.sign(
|
||||
{ user: userID },
|
||||
{ jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT }
|
||||
);
|
||||
|
||||
// Generate the url that a user can land on.
|
||||
const downloadLandingURL = new URL('account/download', BASE_URL);
|
||||
downloadLandingURL.hash = token;
|
||||
|
||||
// Generate the url that the API calls to download the actual zip.
|
||||
const downloadFileURL = new URL('api/v1/account/download', BASE_URL);
|
||||
downloadFileURL.searchParams.set('token', token);
|
||||
|
||||
return {
|
||||
downloadLandingURL: downloadLandingURL.href,
|
||||
downloadFileURL: downloadFileURL.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendDownloadLink(ctx) {
|
||||
const {
|
||||
user,
|
||||
loaders: { Settings },
|
||||
connectors: { services: { Users, I18n, Limit }, models: { User } },
|
||||
} = ctx;
|
||||
|
||||
async function sendDownloadLink({
|
||||
user,
|
||||
loaders: { Settings },
|
||||
connectors: {
|
||||
errors,
|
||||
secrets,
|
||||
services: { Users, I18n, Limit },
|
||||
models: { User },
|
||||
},
|
||||
}) {
|
||||
// downloadLinkLimiter can be used to limit downloads for the user's data to
|
||||
// once every 7 days.
|
||||
const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d');
|
||||
@@ -26,7 +49,7 @@ async function sendDownloadLink({
|
||||
// 7 days.
|
||||
const attempts = await downloadLinkLimiter.get(user.id);
|
||||
if (attempts && attempts >= 1) {
|
||||
throw errors.ErrMaxRateLimit;
|
||||
throw new ErrMaxRateLimit();
|
||||
}
|
||||
|
||||
// Check if the lastAccountDownload time is within 7 days.
|
||||
@@ -36,7 +59,7 @@ async function sendDownloadLink({
|
||||
.add(7, 'days')
|
||||
.isAfter(moment())
|
||||
) {
|
||||
throw errors.ErrMaxRateLimit;
|
||||
throw new ErrMaxRateLimit();
|
||||
}
|
||||
|
||||
// The account currently does not have a download link, let's record the
|
||||
@@ -44,21 +67,18 @@ async function sendDownloadLink({
|
||||
// now.
|
||||
await downloadLinkLimiter.test(user.id);
|
||||
|
||||
// Generate a token for the download link.
|
||||
const token = await secrets.jwt.sign(
|
||||
{ user: user.id },
|
||||
{ jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT }
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Generate the download links.
|
||||
const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id);
|
||||
|
||||
const { organizationName } = await Settings.load('organizationName');
|
||||
|
||||
// Send the download link via the user's attached email account.
|
||||
await Users.sendEmail(user, {
|
||||
template: 'download',
|
||||
locals: {
|
||||
token,
|
||||
downloadLandingURL,
|
||||
organizationName,
|
||||
now,
|
||||
},
|
||||
@@ -125,3 +145,20 @@ module.exports = ctx =>
|
||||
cancelDeletion: () => Promise.reject(new ErrNotAuthorized()),
|
||||
},
|
||||
};
|
||||
// downloadUser will return the download file url that can be used to directly
|
||||
// download the archive.
|
||||
async function downloadUser(ctx, userID) {
|
||||
const { downloadFileURL } = await generateDownloadLinks(ctx, userID);
|
||||
return downloadFileURL;
|
||||
}
|
||||
|
||||
module.exports = ctx => ({
|
||||
User: {
|
||||
requestDownloadLink: () => sendDownloadLink(ctx),
|
||||
download:
|
||||
// Only ADMIN users can execute an account download.
|
||||
ctx.user && ctx.user.role === 'ADMIN'
|
||||
? userID => downloadUser(ctx, userID)
|
||||
: () => Promise.reject(new ErrNotAuthorized()),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@ module.exports = {
|
||||
cancelAccountDeletion: async (_, args, { mutators: { User } }) => {
|
||||
await User.cancelDeletion();
|
||||
},
|
||||
downloadUser: async (_, { id }, { mutators: { User } }) => ({
|
||||
archiveURL: await User.download(id),
|
||||
}),
|
||||
},
|
||||
User: {
|
||||
lastAccountDownload: (user, args, { user: currentUser }) => {
|
||||
|
||||
@@ -20,14 +20,15 @@ async function verifyDownloadToken(
|
||||
|
||||
// loadCommentsBatch will load a batch of the comments and write them to the
|
||||
// stream.
|
||||
async function loadCommentsBatch(ctx, csv, variables = {}) {
|
||||
async function loadCommentsBatch(ctx, csv, variables) {
|
||||
let result = await ctx.graphql(
|
||||
`
|
||||
query GetMyComments($cursor: Cursor) {
|
||||
me {
|
||||
query GetMyComments($userID: ID!, $cursor: Cursor) {
|
||||
user(id: $userID) {
|
||||
comments(query: {
|
||||
limit: 100,
|
||||
cursor: $cursor
|
||||
cursor: $cursor,
|
||||
statuses: null
|
||||
}) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
@@ -50,7 +51,7 @@ async function loadCommentsBatch(ctx, csv, variables = {}) {
|
||||
throw result.errors;
|
||||
}
|
||||
|
||||
for (const comment of get(result, 'data.me.comments.nodes', [])) {
|
||||
for (const comment of get(result, 'data.user.comments.nodes', [])) {
|
||||
csv.write([
|
||||
comment.id,
|
||||
moment(comment.created_at).format('YYYY-MM-DD HH:mm:ss'),
|
||||
@@ -60,12 +61,12 @@ async function loadCommentsBatch(ctx, csv, variables = {}) {
|
||||
]);
|
||||
}
|
||||
|
||||
return pick(result.data.me.comments, ['hasNextPage', 'endCursor']);
|
||||
return pick(get(result, 'data.user.comments'), ['hasNextPage', 'endCursor']);
|
||||
}
|
||||
|
||||
// loadComments will load batches of the comments and write them to the csv
|
||||
// stream. Once the comments have finished writing, it will close the stream.
|
||||
async function loadComments(ctx, archive, latestContentDate) {
|
||||
async function loadComments(ctx, userID, archive, latestContentDate) {
|
||||
// Create all the csv writers that'll write the data to the archive.
|
||||
const csv = stringify();
|
||||
|
||||
@@ -78,12 +79,14 @@ async function loadComments(ctx, archive, latestContentDate) {
|
||||
// from the token.
|
||||
let connection = await loadCommentsBatch(ctx, csv, {
|
||||
cursor: latestContentDate,
|
||||
userID,
|
||||
});
|
||||
|
||||
// As long as there's more comments, keep paginating.
|
||||
while (connection.hasNextPage) {
|
||||
connection = await loadCommentsBatch(ctx, csv, {
|
||||
cursor: connection.endCursor,
|
||||
userID,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,11 +101,21 @@ module.exports = router => {
|
||||
|
||||
// /api/v1/account/download will send back a zipped archive of the users
|
||||
// account.
|
||||
router.post(
|
||||
router.all(
|
||||
'/api/v1/account/download',
|
||||
express.urlencoded({ extended: false }),
|
||||
async (req, res, next) => {
|
||||
const { token = null, check = false } = req.body;
|
||||
let { token = null, check = false } = req.body;
|
||||
|
||||
if (!token) {
|
||||
// If the token wasn't found in the body, then we should check the query
|
||||
// to see if it was passed that way.
|
||||
token = req.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
if (check) {
|
||||
// This request is checking to see if the token is valid.
|
||||
@@ -120,7 +133,7 @@ module.exports = router => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { connectors: { services: { Users } } } = req.context;
|
||||
const { connectors: { graph: { Context }, errors } } = req.context;
|
||||
|
||||
try {
|
||||
// Pull the userID and the date that the token was issued out of the
|
||||
@@ -130,25 +143,31 @@ module.exports = router => {
|
||||
token
|
||||
);
|
||||
|
||||
// Create a system context used to get all comments for that user.
|
||||
const ctx = Context.forSystem();
|
||||
|
||||
// Get the current user's username. We need it for the generated filenames.
|
||||
const result = await ctx.graphql(
|
||||
`query GetUser($userID: ID!) {
|
||||
user(id: $userID) { username }
|
||||
}`,
|
||||
{ userID }
|
||||
);
|
||||
if (result.errors) {
|
||||
throw result.errors;
|
||||
}
|
||||
|
||||
const user = get(result, 'data.user');
|
||||
if (!user) {
|
||||
throw new errors.ErrNotFound();
|
||||
}
|
||||
|
||||
// Unpack the date that the token was issued, and use it as a source for the
|
||||
// earliest comment we should include in the download.
|
||||
const latestContentDate = new Date(iat * 1000);
|
||||
|
||||
// Grab the user that we're generating the export from. We'll use it to
|
||||
// create a new context.
|
||||
const user = await Users.findById(userID);
|
||||
|
||||
// Base a new context off of the new user.
|
||||
const ctx = req.context.masqueradeAs(user);
|
||||
|
||||
// Get the current user's username. We need it for the generated filenames.
|
||||
const result = await ctx.graphql('{ me { username } }');
|
||||
if (result.errors) {
|
||||
throw result.errors;
|
||||
}
|
||||
const username = get(result, 'data.me.username');
|
||||
|
||||
// Generate the filename of the file that the user will download.
|
||||
const username = get(user, 'username');
|
||||
const filename = `talk-${kebabCase(username)}-${kebabCase(
|
||||
moment(latestContentDate).format('YYYY-MM-DD HH:mm:ss')
|
||||
)}.zip`;
|
||||
@@ -167,7 +186,7 @@ module.exports = router => {
|
||||
archive.pipe(res);
|
||||
|
||||
// Load the comments csv up with the user's comments.
|
||||
await loadComments(ctx, archive, latestContentDate);
|
||||
await loadComments(ctx, userID, archive, latestContentDate);
|
||||
|
||||
// Mark the end of adding files, no more files can be added after this. Once
|
||||
// all the stream readers have finished writing, and have closed, the
|
||||
|
||||
@@ -41,6 +41,18 @@ type CancelAccountDeletionResponse implements Response {
|
||||
errors: [UserError!]
|
||||
}
|
||||
|
||||
# DownloadUserResponse contaisn the account download archiveURL that can be used
|
||||
# to directly download a zip file containing the user data.
|
||||
type DownloadUserResponse implements Response {
|
||||
|
||||
# archiveURL is the link that can be used within the next 1 hour to download a
|
||||
# users archive.
|
||||
archiveURL: String
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError!]
|
||||
}
|
||||
|
||||
type RootMutation {
|
||||
|
||||
# requestDownloadLink will request a download link be sent to the primary
|
||||
@@ -54,4 +66,8 @@ type RootMutation {
|
||||
# cancelAccountDeletion will cancel a pending account deletion that was
|
||||
# previously scheduled.
|
||||
cancelAccountDeletion: CancelAccountDeletionResponse
|
||||
|
||||
# downloadUser will provide an account download for the indicated User. This
|
||||
# mutation requires the ADMIN role.
|
||||
downloadUser(id: ID!): DownloadUserResponse
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
en:
|
||||
download_landing:
|
||||
download_your_account: "Download Your Comment History"
|
||||
download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma seperated value (or .csv) file that you can easily import into your favorite spreadsheet application."
|
||||
download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application."
|
||||
all_information_included: "For each of your comments the following information is included:"
|
||||
information_included:
|
||||
date: "When you wrote the comment"
|
||||
|
||||
@@ -16,6 +16,9 @@ Enables secure rich text support server-side.
|
||||
Add `"talk-plugin-rich-text"` to the `plugins.json` in your Talk installation.
|
||||
This plugin provides a server and a client side implementation.
|
||||
|
||||
###### Note: Possible plugin conflict
|
||||
The plugin `talk-plugin-comment-content` will prevent this plugin from rendering comments with rich text styling and is not needed if this plugin is enabled.
|
||||
|
||||
## Server implementation
|
||||
|
||||
### How does this work?
|
||||
@@ -43,11 +46,11 @@ Settings for highlighting links. These will only apply if `higlightLinks` is set
|
||||
|
||||
#### `dompurify`
|
||||
|
||||
Rules to sanitize html input. We use [DOMPurify] (https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings] (https://github.com/cure53/DOMPurify)
|
||||
Rules to sanitize html input. We use [DOMPurify](https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings](https://github.com/cure53/DOMPurify)
|
||||
|
||||
#### `jsdom`
|
||||
|
||||
In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings.
|
||||
In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldn’t need to modify this settings.
|
||||
|
||||
## Client implementation
|
||||
|
||||
@@ -58,10 +61,10 @@ This plugin contains 2 important components:
|
||||
- The Editor (`./components/Editor.js`)
|
||||
- The Comment Content Renderer (`./components/CommentContent.js`)
|
||||
|
||||
The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API.
|
||||
The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API.
|
||||
|
||||
If you check our `index.js` you will notice that we inject this editor in the
|
||||
`commentBox` slot. We do this to replace the core comment box with this one.
|
||||
`commentBox` slot. We do this to replace the core comment box with this one.
|
||||
|
||||
Now, in order to render the new styled comments we need a comment renderer. For
|
||||
this task we will have to replace our core comment renderer by using the
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Beliebteste zuerst
|
||||
es:
|
||||
talk-plugin-sort-most-liked:
|
||||
label: Most liked first
|
||||
label: Más valoradas primero
|
||||
fr:
|
||||
talk-plugin-sort-most-liked:
|
||||
label: Most liked first
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Häufigste "Ich liebe es" zuerst
|
||||
es:
|
||||
talk-plugin-sort-most-loved:
|
||||
label: Most loved first
|
||||
label: Más amadas primero
|
||||
fr:
|
||||
talk-plugin-sort-most-loved:
|
||||
label: Most loved first
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Häufigste Antworten zuerst
|
||||
es:
|
||||
talk-plugin-sort-most-replied:
|
||||
label: Most replied first
|
||||
label: Más respondidas primero
|
||||
fr:
|
||||
talk-plugin-sort-most-replied:
|
||||
label: Most replied first
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Häufigste "Respektiert" zuerst
|
||||
es:
|
||||
talk-plugin-sort-most-respected:
|
||||
label: Most respected first
|
||||
label: Más respetadas primero
|
||||
fr:
|
||||
talk-plugin-sort-most-respected:
|
||||
label: Most respected first
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
en:
|
||||
talk-plugin-sort-most-upvoted:
|
||||
label: Most upvoted first
|
||||
es:
|
||||
talk-plugin-sort-oldest:
|
||||
label: Más votadas primero
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Neueste zuerst
|
||||
es:
|
||||
talk-plugin-sort-newest:
|
||||
label: Newest first
|
||||
label: Más nuevas primero
|
||||
fr:
|
||||
talk-plugin-sort-newest:
|
||||
label: Newest first
|
||||
|
||||
@@ -12,7 +12,7 @@ de:
|
||||
label: Älteste zuerst
|
||||
es:
|
||||
talk-plugin-sort-oldest:
|
||||
label: Oldest first
|
||||
label: Más viejas primero
|
||||
fr:
|
||||
talk-plugin-sort-oldest:
|
||||
label: Oldest first
|
||||
|
||||
@@ -21,8 +21,8 @@ de:
|
||||
es:
|
||||
talk-plugin-viewing-options:
|
||||
viewing_options: "Opciones de visualización"
|
||||
sort: Sorting
|
||||
filter: Filtering
|
||||
sort: Ordenado por
|
||||
filter: Filtrado por
|
||||
fr:
|
||||
talk-plugin-viewing-options:
|
||||
viewing_options: "Viewing Options"
|
||||
|
||||
Reference in New Issue
Block a user