Friendship management added

This commit is contained in:
Torsten Schulz
2024-10-27 13:14:05 +01:00
parent f74a16e58e
commit 7f8709516d
13 changed files with 406 additions and 31 deletions

View File

@@ -23,6 +23,9 @@ class SocialNetworkController {
this.updateDiaryEntry = this.updateDiaryEntry.bind(this); this.updateDiaryEntry = this.updateDiaryEntry.bind(this);
this.deleteDiaryEntry = this.deleteDiaryEntry.bind(this); this.deleteDiaryEntry = this.deleteDiaryEntry.bind(this);
this.getDiaryEntries = this.getDiaryEntries.bind(this); this.getDiaryEntries = this.getDiaryEntries.bind(this);
this.addFriend = this.addFriend.bind(this);
this.removeFriend = this.removeFriend.bind(this);
this.acceptFriendship = this.acceptFriendship.bind(this);
} }
async userSearch(req, res) { async userSearch(req, res) {
@@ -287,6 +290,42 @@ class SocialNetworkController {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
} }
async addFriend(req, res) {
try {
const { userid: hashedUserid } = req.headers;
const { friendUserid } = req.body;
await this.socialNetworkService.addFriend(hashedUserid, friendUserid);
res.status(201).json({ message: 'added' });
} catch (error) {
console.error('Error in addFriend:', error);
res.status(500).json({ error: error.message });
}
}
async removeFriend(req, res) {
try {
const { userid: hashedUserid } = req.headers;
const { friendUserid } = req.params;
await this.socialNetworkService.removeFriend(hashedUserid, friendUserid);
res.status(200).json({ message: 'removed' });
} catch (error) {
console.error('Error in removeFriend:', error);
res.status(500).json({ error: error.message });
}
}
async acceptFriendship(req, res) {
try {
const { userid: hashedUserid } = req.headers;
const { friendUserid } = req.params;
await this.socialNetworkService.acceptFriendship(hashedUserid, friendUserid);
res.status(200).json({ message: 'accepted' });
} catch (error) {
console.error('Error in acceptFriendship:', error);
res.status(500).json({ error: error.message });
}
}
} }
export default SocialNetworkController; export default SocialNetworkController;

View File

@@ -27,6 +27,7 @@ import TitleHistory from './forum/title_history.js';
import ForumPermission from './forum/forum_permission.js'; import ForumPermission from './forum/forum_permission.js';
import ForumUserPermission from './forum/forum_user_permission.js'; import ForumUserPermission from './forum/forum_user_permission.js';
import ForumForumPermission from './forum/forum_forum_permission.js'; import ForumForumPermission from './forum/forum_forum_permission.js';
import Friendship from './community/friendship.js';
export default function setupAssociations() { export default function setupAssociations() {
// UserParam related associations // UserParam related associations
@@ -156,4 +157,9 @@ export default function setupAssociations() {
ForumPermission.hasMany(ForumUserPermission, { foreignKey: 'permissionId' }); ForumPermission.hasMany(ForumUserPermission, { foreignKey: 'permissionId' });
ForumUserPermission.belongsTo(ForumPermission, { foreignKey: 'permissionId' }); ForumUserPermission.belongsTo(ForumPermission, { foreignKey: 'permissionId' });
Friendship.belongsTo(User, { foreignKey: 'user1Id', as: 'friendSender' });
Friendship.belongsTo(User, { foreignKey: 'user2Id', as: 'friendReceiver' });
User.hasMany(Friendship, { foreignKey: 'user1Id', as: 'friendSender' });
User.hasMany(Friendship, { foreignKey: 'user2Id', as: 'friendReceiver' });
} }

View File

@@ -0,0 +1,37 @@
import { sequelize } from '../../utils/sequelize.js';
import { DataTypes } from 'sequelize';
const Friendship = sequelize.define('friendship', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
user1Id: {
type: DataTypes.INTEGER,
allowNull: false
},
user2Id: {
type: DataTypes.INTEGER,
allowNull: false
},
accepted: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
denied: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
withdrawn: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
}, {
tableName: 'friendship',
schema: 'community',
underscored: true,
timestamps: true,
});
export default Friendship;

View File

@@ -31,6 +31,7 @@ import Message from './forum/message.js';
import MessageHistory from './forum/message_history.js'; import MessageHistory from './forum/message_history.js';
import MessageImage from './forum/message_image.js'; import MessageImage from './forum/message_image.js';
import ForumForumPermission from './forum/forum_forum_permission.js'; import ForumForumPermission from './forum/forum_forum_permission.js';
import Friendship from './community/friendship.js';
const models = { const models = {
SettingsType, SettingsType,
@@ -66,6 +67,7 @@ const models = {
Message, Message,
MessageHistory, MessageHistory,
MessageImage, MessageImage,
Friendship,
}; };
export default models; export default models;

View File

@@ -7,25 +7,30 @@ const upload = multer();
const router = express.Router(); const router = express.Router();
const socialNetworkController = new SocialNetworkController(); const socialNetworkController = new SocialNetworkController();
router.post('/usersearch', authenticate, socialNetworkController.userSearch); router.use(authenticate);
router.get('/profile/main/:userId', authenticate, socialNetworkController.profile);
router.post('/folders/:folderId', authenticate, socialNetworkController.createFolder); router.post('/usersearch', socialNetworkController.userSearch);
router.get('/folders', authenticate, socialNetworkController.getFolders); router.get('/profile/main/:userId', socialNetworkController.profile);
router.get('/folder/:folderId', authenticate, socialNetworkController.getFolderImageList); router.post('/folders/:folderId', socialNetworkController.createFolder);
router.post('/images', authenticate, upload.single('image'), socialNetworkController.uploadImage); router.get('/folders', socialNetworkController.getFolders);
router.get('/images/:imageId', authenticate, socialNetworkController.getImage); router.get('/folder/:folderId', socialNetworkController.getFolderImageList);
router.put('/images/:imageId', authenticate, socialNetworkController.changeImage); router.post('/images', upload.single('image'), socialNetworkController.uploadImage);
router.get('/imagevisibilities', authenticate, socialNetworkController.getImageVisibilityTypes); router.get('/images/:imageId', socialNetworkController.getImage);
router.get('/image/:hash', authenticate, socialNetworkController.getImageByHash); router.put('/images/:imageId', socialNetworkController.changeImage);
router.get('/profile/images/folders/:username', authenticate, socialNetworkController.getFoldersByUsername); router.get('/imagevisibilities', socialNetworkController.getImageVisibilityTypes);
router.delete('/folders/:folderId', authenticate, socialNetworkController.deleteFolder); router.get('/image/:hash', socialNetworkController.getImageByHash);
router.post('/guestbook/entries', authenticate, upload.single('image'), socialNetworkController.createGuestbookEntry); router.get('/profile/images/folders/:username', socialNetworkController.getFoldersByUsername);
router.get('/guestbook/entries/:username/:page', authenticate, socialNetworkController.getGuestbookEntries); router.delete('/folders/:folderId', socialNetworkController.deleteFolder);
router.delete('/guestbook/entries/:entryId', authenticate, socialNetworkController.deleteGuestbookEntry); router.post('/guestbook/entries', upload.single('image'), socialNetworkController.createGuestbookEntry);
router.get('/guestbook/image/:guestbookUserName/:entryId', authenticate, socialNetworkController.getGuestbookImage); router.get('/guestbook/entries/:username/:page', socialNetworkController.getGuestbookEntries);
router.post('/diary', authenticate, socialNetworkController.createDiaryEntry); router.delete('/guestbook/entries/:entryId', socialNetworkController.deleteGuestbookEntry);
router.put('/diary/:diaryEntryId', authenticate, socialNetworkController.updateDiaryEntry); router.get('/guestbook/image/:guestbookUserName/:entryId', socialNetworkController.getGuestbookImage);
router.delete('/diary/:entryId', authenticate, socialNetworkController.deleteDiaryEntry); router.post('/diary', socialNetworkController.createDiaryEntry);
router.get('/diary/:page', authenticate, socialNetworkController.getDiaryEntries); router.put('/diary/:diaryEntryId', socialNetworkController.updateDiaryEntry);
router.delete('/diary/:entryId', socialNetworkController.deleteDiaryEntry);
router.get('/diary/:page', socialNetworkController.getDiaryEntries);
router.post('/friend', socialNetworkController.addFriend);
router.delete('/friend/:friendUserId', socialNetworkController.removeFriend);
router.put('/friend/:friendUserId', socialNetworkController.acceptFriendship);
export default router; export default router;

View File

@@ -23,6 +23,7 @@ import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import sharp from 'sharp'; import sharp from 'sharp';
import Diary from '../models/community/diary.js'; import Diary from '../models/community/diary.js';
import Friendship from '../models/community/friendship.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -347,7 +348,15 @@ class SocialNetworkService extends BaseService {
{ model: UserParamType, as: 'paramType' }, { model: UserParamType, as: 'paramType' },
{ model: UserParamVisibility, as: 'param_visibilities', include: [{ model: UserParamVisibilityType, as: 'visibility_type' }] } { model: UserParamVisibility, as: 'param_visibilities', include: [{ model: UserParamVisibilityType, as: 'visibility_type' }] }
], ],
order: [['order_id', 'asc']] order: [['order_id', 'asc']],
},
{
model: Friendship,
as: 'friendSender',
},
{
model: Friendship,
as: 'friendReceiver',
} }
] ]
}); });
@@ -370,9 +379,26 @@ class SocialNetworkService extends BaseService {
}; };
} }
} }
let friendship = null;
if (user.friendSender && user.friendSender.length > 0) {
friendship = {
isSender: true,
accepted: user.friendSender[0].dataValues.accepted,
denied: user.friendSender[0].dataValues.denied,
withdrawn: user.friendSender[0].dataValues.withdrawn,
}
} else if (user.friendReceiver && user.friendReceiver.length > 0) {
friendship = {
isSender: false,
accepted: user.friendReceiver[0].dataValues.accepted,
denied: user.friendReceiver[0].dataValues.denied,
withdrawn: user.friendReceiver[0].dataValues.withdrawn,
}
}
return { return {
username: user.username, username: user.username,
registrationDate: user.registrationDate, registrationDate: user.registrationDate,
friendship: friendship,
params: userParams params: userParams
}; };
} }
@@ -707,5 +733,83 @@ class SocialNetworkService extends BaseService {
}); });
return { entries: entries.rows, totalPages: Math.ceil(entries.count / 20) }; return { entries: entries.rows, totalPages: Math.ceil(entries.count / 20) };
} }
async addFriend(hashedUserid, friendUserid) {
const requestingUserId = await this.checkUserAccess(hashedUserid);
const friend = await this.loadUserByHash(friendUserid);
if (!friend) {
throw new Error('notfound');
}
const friendship = await Friendship.findOne({
where: {
[Op.or]: [
{ user1Id: requestingUserId, user2Id: friend.id },
{ user1Id: friend.id, user2Id: requestingUserId }
]
}
});
if (friendship) {
if (friendship.withdrawn) {
friendship.withdrawn = false;
} else {
throw new Error('alreadyexists');
}
} else {
await Friendship.create({ user1Id: requestingUserId, user2Id: friend.id });
}
return { accepted: false, withdrawn: false, denied: false };
}
async removeFriend(hashedUserid, friendUserid) {
const requestingUserId = await this.checkUserAccess(hashedUserid);
const friend = await this.loadUserByHash(friendUserid);
if (!friend) {
throw new Error('notfound');
}
const friendship = await Friendship.findOne({
where: {
[Op.or]: [
{ user1Id: requestingUserId, user2Id: friend.id },
{ user1Id: friend.id, user2Id: requestingUserId }
]
}
});
if (!friendship) {
throw new Error('notfound');
}
if (friendship.user1Id === requestingUserId) {
friendship.update({ withdrawn: true })
} else {
friendship.update({ denied: true });
}
return true;
}
async acceptFriendship(hashedUserid, friendUserid) {
const requestingUserId = await this.checkUserAccess(hashedUserid);
const friend = await this.loadUserByHash(friendUserid);
if (!friend) {
throw new Error('notfound');
}
const friendship = await Friendship.findOne({
where: {
[Op.or]: [
{ user1Id: requestingUserId, user2Id: friend.id },
{ user1Id: friend.id, user2Id: requestingUserId }
]
}
});
if (!friendship) {
throw new Error('notfound');
}
if (friendship.user1Id === requestingUserId && friendship.withdrawn) {
friendship.update({ withdrawn: false });
} else if (friendship.user2Id === requestingUserId && friendship.denied) {
friendship.update({ denied: false, accepted: true });
} else {
throw new Error('notfound');
}
}
} }
export default SocialNetworkService; export default SocialNetworkService;

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -14,7 +14,9 @@
<DataPrivacyDialog ref="dataPrivacyDialog" /> <DataPrivacyDialog ref="dataPrivacyDialog" />
<ErrorDialog ref="errorDialog" /> <ErrorDialog ref="errorDialog" />
<ImprintDialog ref="imprintDialog" /> <ImprintDialog ref="imprintDialog" />
<ShowImageDialog ref="showImageDialog" /></div> <ShowImageDialog ref="showImageDialog" />
<MessageDialog ref="messageDialog" />
</div>
</template> </template>
<script> <script>
@@ -34,6 +36,7 @@ import DataPrivacyDialog from './dialogues/standard/DataPrivacyDialog.vue';
import ErrorDialog from './dialogues/standard/ErrorDialog.vue'; import ErrorDialog from './dialogues/standard/ErrorDialog.vue';
import ImprintDialog from './dialogues/standard/ImprintDialog.vue'; import ImprintDialog from './dialogues/standard/ImprintDialog.vue';
import ShowImageDialog from './dialogues/socialnetwork/ShowImageDialog.vue'; import ShowImageDialog from './dialogues/socialnetwork/ShowImageDialog.vue';
import MessageDialog from './dialogues/standard/MessageDialog.vue';
export default { export default {
name: 'App', name: 'App',
@@ -59,6 +62,7 @@ export default {
ErrorDialog, ErrorDialog,
ImprintDialog, ImprintDialog,
ShowImageDialog, ShowImageDialog,
MessageDialog,
}, },
created() { created() {
this.$store.dispatch('loadLoginState'); this.$store.dispatch('loadLoginState');

View File

@@ -1,5 +1,6 @@
<template> <template>
<div v-if="visible" :class="['dialog-overlay', { 'non-modal': !modal, 'is-active': isActive }]" @click.self="handleOverlayClick"> <div v-if="visible" :class="['dialog-overlay', { 'non-modal': !modal, 'is-active': isActive }]"
@click.self="handleOverlayClick">
<div class="dialog" :class="{ minimized: minimized }" <div class="dialog" :class="{ minimized: minimized }"
:style="{ width: dialogWidth, height: dialogHeight, top: dialogTop, left: dialogLeft, position: 'absolute' }" :style="{ width: dialogWidth, height: dialogHeight, top: dialogTop, left: dialogLeft, position: 'absolute' }"
v-if="!minimized" ref="dialog"> v-if="!minimized" ref="dialog">
@@ -11,7 +12,7 @@
<span v-if="!modal" class="dialog-minimize" @click="minimize">_</span> <span v-if="!modal" class="dialog-minimize" @click="minimize">_</span>
<span v-if="showClose" class="dialog-close" @click="close"></span> <span v-if="showClose" class="dialog-close" @click="close"></span>
</div> </div>
<div class="dialog-body"> <div class="dialog-body" :style="{ '--dialog-display': display }">
<slot></slot> <slot></slot>
</div> </div>
<div class="dialog-footer"> <div class="dialog-footer">
@@ -62,6 +63,10 @@ export default {
isTitleTranslated: { isTitleTranslated: {
type: Boolean, type: Boolean,
default: false default: false
},
display: {
type: String,
default: 'block'
} }
}, },
data() { data() {
@@ -170,7 +175,7 @@ export default {
}; };
</script> </script>
<style scoped> <style lang="scss" scoped>
.dialog-overlay { .dialog-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -236,9 +241,13 @@ export default {
flex-grow: 1; flex-grow: 1;
padding: 20px; padding: 20px;
overflow-y: auto; overflow-y: auto;
display: var(--dialog-display);
&[style*="--dialog-display: flex"] {
flex-direction: column;
}
} }
.dialog-footer { dialog-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 10px 20px; padding: 10px 20px;
@@ -261,6 +270,7 @@ export default {
color: #7E471B; color: #7E471B;
border: 1px solid #7E471B; border: 1px solid #7E471B;
} }
.is-active { .is-active {
z-index: 990; z-index: 990;
} }

View File

@@ -1,8 +1,15 @@
<template> <template>
<DialogWidget ref="dialog" :title="$t('socialnetwork.profile.pretitle')" :isTitleTranslated="isTitleTranslated" <DialogWidget ref="dialog" :title="$t('socialnetwork.profile.pretitle')" :isTitleTranslated="isTitleTranslated"
:show-close="true" :buttons="[{ text: 'Ok', action: 'close' }]" :modal="false" @close="closeDialog" height="75%" :show-close="true" :buttons="[{ text: 'Ok', action: 'close' }]" :modal="false" @close="closeDialog" height="75%"
name="UserProfileDialog"> name="UserProfileDialog" display="flex">
<div class="dialog-body"> <div class="activities">
<span>{{ $t(`socialnetwork.friendship.state.${friendshipState}`) }}</span>
<img v-if="['none', 'denied', 'withdrawn'].includes(friendshipState)" src="/images/icons/request-friendship.png"
@click="handleFriendship()" />
<img v-else-if="['accepted', 'open']" src="/images/icons/cancel-friendship.png"
@click="handleFriendship()" />
</div>
<div class="profile-content">
<div> <div>
<ul class="tab-list"> <ul class="tab-list">
<li v-for="tab in tabs" :key="tab.name" :class="{ active: activeTab === tab.name }" <li v-for="tab in tabs" :key="tab.name" :class="{ active: activeTab === tab.name }"
@@ -137,6 +144,8 @@ export default {
menubar: 'edit format table', menubar: 'edit format table',
promotion: false, promotion: false,
}, },
hasSendFriendshipRequest: false,
friendshipState: 'none',
}; };
}, },
methods: { methods: {
@@ -148,6 +157,7 @@ export default {
try { try {
const response = await apiClient.get(`/api/socialnetwork/profile/main/${this.userId}`); const response = await apiClient.get(`/api/socialnetwork/profile/main/${this.userId}`);
this.userProfile = response.data; this.userProfile = response.data;
this.setFriendshipStatus(response.data.friendship);
const newTitle = this.$t('socialnetwork.profile.title').replace('<username>', this.userProfile.username); const newTitle = this.$t('socialnetwork.profile.title').replace('<username>', this.userProfile.username);
this.$refs.dialog.updateTitle(newTitle, false); this.$refs.dialog.updateTitle(newTitle, false);
if (this.activeTab === 'images') { if (this.activeTab === 'images') {
@@ -300,7 +310,6 @@ export default {
}, },
async fetchGuestbookImage(guestbookOwnerName, entry) { async fetchGuestbookImage(guestbookOwnerName, entry) {
try { try {
console.log(entry, guestbookOwnerName);
const response = await apiClient.get(`/api/socialnetwork/guestbook/image/${guestbookOwnerName}/${entry.id}`, { const response = await apiClient.get(`/api/socialnetwork/guestbook/image/${guestbookOwnerName}/${entry.id}`, {
responseType: 'blob', responseType: 'blob',
}); });
@@ -309,11 +318,75 @@ export default {
console.error('Error fetching image:', error); console.error('Error fetching image:', error);
} }
}, },
async handleFriendship() {
console.log(this.friendshipState);
if (this.friendshipState === 'none') {
this.requestFriendship();
} else if (this.friendshipState === 'waiting') {
this.cancelFriendship();
} else if (this.friendshipState === 'accepted') {
this.cancelFriendship();
} else if (this.friendshipState === 'denied') {
this.acceptFriendship();
}
},
async requestFriendship() {
try {
const response = await apiClient.post('/api/socialnetwork/friend', {
friendUserid: this.userId,
});
this.setFriendshipStatus(response.data);
this.$root.$refs.messageDialog.open('tr:socialnetwork.friendship.added');
} catch(error) {
this.$root.$refs.errorDialog.open(`tr:socialnetwork.friendship.error.${error.response.data.error}`);
}
},
async cancelFriendship() {
try {
await apiClient.delete(`/api/socialnetwork/friend/${this.userId}`);
this.setFriendshipStatus(null);
const type = this.friendshipState === 'waiting' ? 'withdrawn' : 'denied'
this.$root.$refs.messageDialog.open(`tr:socialnetwork.friendship.${type}`);
} catch(error) {
this.$root.$refs.errorDialog.open(`tr:socialnetwork.friendship.error.${error.response.data.error}`);
}
},
async acceptFriendship() {
try {
await apiClient.put(`/api/socialnetwork/friend/${this.userId}`);
this.setFriendshipStatus(null);
this.$root.$refs.messageDialog.open('Freundschaftsanfrage akzeptiert');
} catch(error) {
this.$root.$refs.errorDialog.open(`tr:socialnetwork.friendship.error.${error.response.data.error}`);
}
},
setFriendshipStatus(friendshipStates) {
if (!friendshipStates) {
this.friendshipState = 'none';
return;
}
this.hasSendFriendshipRequest = friendshipStates.isSender;
if (friendshipStates.accepted) {
this.friendshipState = 'accepted'
} else if (friendshipStates.denied) {
this.friendshipState = 'denied';
} else if (friendshipStates.withdrawn) {
this.friendshipState = 'withdrawn';
} else if (!friendshipStates.isSender) {
this.friendshipState = 'open';
} else {
this.friendshipState = 'waiting';
}
}
} }
}; };
</script> </script>
<style scoped> <style scoped>
.dialog-body > div:first-child {
display: block;
}
.tab-list { .tab-list {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
@@ -431,4 +504,30 @@ export default {
.pagination button { .pagination button {
margin: 0 10px; margin: 0 10px;
} }
.activities {
background-color: #F9A22C;
margin: -20px -20px 0 -20px;
height: 26px !important;
display: flex !important;
flex-direction: row !important;
}
.activities > span:first-child {
flex: 1;
}
.activities > img {
cursor: pointer;
}
.userprofile-content {
display: flex;
flex-direction: column;
}
.profile-content {
flex: 1;
overflow: auto;
}
</style> </style>

View File

@@ -0,0 +1,52 @@
<template>
<DialogWidget ref="dialog" title="message.title" :show-close="true" :buttons="buttons" :modal="true" width="25em"
height="15em" name="MessageDialog" :isTitleTranslated=true>
<div class="message-content">
<p>{{ translatedMessage }}</p>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
export default {
name: 'MessageDialog',
components: {
DialogWidget,
},
data() {
return {
message: '',
buttons: [
{ text: 'message.close', action: 'close' }
]
};
},
computed: {
translatedMessage() {
if (this.message.startsWith('tr:')) {
return this.$t(this.message.substring(3));
}
return this.message;
}
},
methods: {
open(message) {
this.message = message;
this.$refs.dialog.open();
},
close() {
this.$refs.dialog.close();
}
}
};
</script>
<style scoped>
.message-content {
padding: 1em;
color: #000000;
text-align: center;
}
</style>

View File

@@ -231,6 +231,23 @@
"page": "Seite <<page>> von <<of>>" "page": "Seite <<page>> von <<of>>"
}, },
"createNewMesssage": "Antwort senden" "createNewMesssage": "Antwort senden"
},
"friendship": {
"error": {
"alreadyexists": "Die Freundschaftsanfrage existiert bereits"
},
"state": {
"none": "Nicht befreundet",
"waiting": "Freundschaftsanfrage gesendet, aber nicht beantwortet",
"open": "Freundschaft wurde angefragt",
"denied": "Freundschaftsanfrage abgelehnt",
"withdrawn": "Freundschaftsanfrage zurückgezogen",
"accepted": "Befreundet"
},
"added": "Du hast eine Freundschaftsanfrage gestellt.",
"withdrawn": "Du hast Deine Freundschaftsanfrage zurückgezogen.",
"denied": "Du hast die Freundschaftsanfrage abgelehnt.",
"accepted": "Die Freundschaft wurde geschlossen."
} }
} }
} }