Use new Mastodon like API

Revert "Revert "Merge pull request #1581 from nextcloud/artonge/feat/use_new_api""

This reverts commit c4eef0b2d4.

Signed-off-by: Louis Chemineau <louis@chmn.me>

Adapt views to new timeline api

Signed-off-by: Louis Chemineau <louis@chmn.me>

Add types

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix type errors

Signed-off-by: Louis Chemineau <louis@chmn.me>

Adapte front-end to mastodon data format

+ Add typing
+ Modernize code

Signed-off-by: Louis Chemineau <louis@chmn.me>

Use new API for media attachments

+ Split Composer.vue into tinier composent
+ Use blurhash value of attachments

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix media attachment rendering in post component

Signed-off-by: Louis Chemineau <louis@chmn.me>

Use square container to display statuses attachments

Signed-off-by: Louis Chemineau <louis@chmn.me>

Add typing to timeline.js

And fix type errors

Signed-off-by: Louis Chemineau <louis@chmn.me>

Forward format to getStreamSelectSql for direct timeline

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix liked timeline

Signed-off-by: Louis Chemineau <louis@chmn.me>

Use new API for local and federated timelines

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix profile and avatar for local users

Signed-off-by: Louis Chemineau <louis@chmn.me>

Update babel config

Signed-off-by: Louis Chemineau <louis@chmn.me>

Improve typing in account.js

Signed-off-by: Louis Chemineau <louis@chmn.me>

Handle new notification format

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix follow button

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix condition of delete button for statuses

Signed-off-by: Louis Chemineau <louis@chmn.me>

Add relationship fetching

Signed-off-by: Louis Chemineau <louis@chmn.me>

Improve attachments viewer

Signed-off-by: Louis Chemineau <louis@chmn.me>

Correctly use twemoji

Signed-off-by: Louis Chemineau <louis@chmn.me>

Clean up composer

Signed-off-by: Louis Chemineau <louis@chmn.me>

Insert emoji on the last line instead of creating a new one

Signed-off-by: Louis Chemineau <louis@chmn.me>

Overall improvements in composer

Signed-off-by: Louis Chemineau <louis@chmn.me>

Clean up PreviewGridItem

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix fetching relationships

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix followers and following list

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix direct link to followers and following lists

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix notifications endpoint

Signed-off-by: Louis Chemineau <louis@chmn.me>

Handle different types of notifications

Signed-off-by: Louis Chemineau <louis@chmn.me>

Add formatted date as title for statuses

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix entryContent computed property

Signed-off-by: Louis Chemineau <louis@chmn.me>

Handle reblog

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix favourite type

Signed-off-by: Louis Chemineau <louis@chmn.me>

Load context of status for single post

Signed-off-by: Louis Chemineau <louis@chmn.me>

Use new format to set the uid in single post

Signed-off-by: Louis Chemineau <louis@chmn.me>

Fix display name property

Signed-off-by: Louis Chemineau <louis@chmn.me>

Hack to handle context of single post

Signed-off-by: Louis Chemineau <louis@chmn.me>

Use item id to fetch context

Signed-off-by: Louis Chemineau <louis@chmn.me>

Remove unsused variable

Signed-off-by: Louis Chemineau <louis@chmn.me>
pull/1615/head
Louis Chemineau 2023-01-19 17:18:11 +01:00
rodzic 991a49c7b9
commit ab347790a8
47 zmienionych plików z 1816 dodań i 1045 usunięć

Wyświetl plik

@ -1,14 +1,3 @@
module.exports = { const babelConfig = require('@nextcloud/babel-config')
plugins: ['@babel/plugin-syntax-dynamic-import'],
presets: [
[
'@babel/preset-env',
{
targets: {
browsers: ['last 2 versions', 'ie >= 11']
}
}
]
]
}
module.exports = babelConfig

Wyświetl plik

@ -1,24 +0,0 @@
@include icon-black-white('reply', 'social', 1);
@include icon-black-white('emoji', 'social', 1);
@include icon-black-white('boost', 'social', 1);
@include icon-black-white('upload', 'actions', 1, true);
@include icon-black-white('notifications', 'social', 1);
.icon-boosted {
@include icon-color('boost', 'social', '#0082c9', 1);
}
.icon-upload {
@include icon-color('upload', 'actions', $color-black, 1, true);
}
img.emoji {
margin: 3px;
width: 16px;
vertical-align: text-bottom;
}
// quick fix - TODO - remove this and fix the left panel
ul.app-navigation__list {
display: none !important;
}

Wyświetl plik

@ -554,7 +554,7 @@ class ApiController extends Controller {
int $limit = 20, int $limit = 20,
int $max_id = 0, int $max_id = 0,
int $min_id = 0, int $min_id = 0,
int $since = 0 int $since_id = 0
): DataResponse { ): DataResponse {
try { try {
$this->initViewer(true); $this->initViewer(true);
@ -568,7 +568,7 @@ class ApiController extends Controller {
->setLimit($limit) ->setLimit($limit)
->setMaxId($max_id) ->setMaxId($max_id)
->setMinId($min_id) ->setMinId($min_id)
->setSince($since); ->setSince($since_id);
$posts = $this->streamService->getTimeline($options); $posts = $this->streamService->getTimeline($options);

Wyświetl plik

@ -37,6 +37,7 @@ use Exception;
use OCA\Social\AppInfo\Application; use OCA\Social\AppInfo\Application;
use OCA\Social\Exceptions\AccountDoesNotExistException; use OCA\Social\Exceptions\AccountDoesNotExistException;
use OCA\Social\Exceptions\InvalidResourceException; use OCA\Social\Exceptions\InvalidResourceException;
use OCA\Social\Model\ActivityPub\ACore;
use OCA\Social\Model\ActivityPub\Actor\Person; use OCA\Social\Model\ActivityPub\Actor\Person;
use OCA\Social\Model\ActivityPub\Object\Note; use OCA\Social\Model\ActivityPub\Object\Note;
use OCA\Social\Model\ActivityPub\Stream; use OCA\Social\Model\ActivityPub\Stream;
@ -537,8 +538,9 @@ class LocalController extends Controller {
$actor = $this->cacheActorService->getFromLocalAccount($username); $actor = $this->cacheActorService->getFromLocalAccount($username);
$actor->setCompleteDetails(true); $actor->setCompleteDetails(true);
$actor->setExportFormat(ACore::FORMAT_LOCAL);
return $this->success(['account' => $actor]); return new DataResponse($actor, Http::STATUS_OK);
} catch (Exception $e) { } catch (Exception $e) {
return $this->fail($e); return $this->fail($e);
} }
@ -588,8 +590,9 @@ class LocalController extends Controller {
$this->initViewer(); $this->initViewer();
$actor = $this->cacheActorService->getFromAccount($account); $actor = $this->cacheActorService->getFromAccount($account);
$actor->setExportFormat(ACore::FORMAT_LOCAL);
return $this->success(['account' => $actor]); return new DataResponse($actor, Http::STATUS_OK);
} catch (Exception $e) { } catch (Exception $e) {
return $this->fail($e); return $this->fail($e);
} }

Wyświetl plik

@ -451,7 +451,7 @@ class StreamRequest extends StreamRequestBuilder {
* @return Stream[] * @return Stream[]
*/ */
private function getTimelineDirect(ProbeOptions $options): array { private function getTimelineDirect(ProbeOptions $options): array {
$qb = $this->getStreamSelectSql(); $qb = $this->getStreamSelectSql($options->getFormat());
$qb->filterType(SocialAppNotification::TYPE); $qb->filterType(SocialAppNotification::TYPE);
$qb->paginate($options); $qb->paginate($options);
@ -476,7 +476,7 @@ class StreamRequest extends StreamRequestBuilder {
* @return Stream[] * @return Stream[]
*/ */
private function getTimelineAccount(ProbeOptions $options): array { private function getTimelineAccount(ProbeOptions $options): array {
$qb = $this->getStreamSelectSql(); $qb = $this->getStreamSelectSql($options->getFormat());
$qb->filterType(SocialAppNotification::TYPE); $qb->filterType(SocialAppNotification::TYPE);
$qb->paginate($options); $qb->paginate($options);

Wyświetl plik

@ -592,7 +592,7 @@ class Stream extends ACore implements IQueryRow, JsonSerializable {
public function exportAsNotification(): array { public function exportAsNotification(): array {
switch ($this->getSubType()) { switch ($this->getSubType()) {
case Like::TYPE: case Like::TYPE:
$type = 'favourites'; $type = 'favourite';
break; break;
case Announce::TYPE: case Announce::TYPE:
$type = 'mention'; $type = 'mention';

306
package-lock.json wygenerowano
Wyświetl plik

@ -19,6 +19,8 @@
"@nextcloud/router": "^2.0.1", "@nextcloud/router": "^2.0.1",
"@nextcloud/vue": "^7.4.0", "@nextcloud/vue": "^7.4.0",
"@nextcloud/vue-richtext": "^2.0.4", "@nextcloud/vue-richtext": "^2.0.4",
"blurhash": "^2.0.5",
"debounce": "^1.2.1",
"he": "^1.2.0", "he": "^1.2.0",
"linkify-plugin-mention": "^4.1.0", "linkify-plugin-mention": "^4.1.0",
"linkify-string": "^4.1.0", "linkify-string": "^4.1.0",
@ -30,8 +32,8 @@
"v-tooltip": "^4.0.0-beta.0", "v-tooltip": "^4.0.0-beta.0",
"vue": "^2.7.10", "vue": "^2.7.10",
"vue-click-outside": "^1.0.7", "vue-click-outside": "^1.0.7",
"vue-contenteditable-directive": "^1.2.0",
"vue-infinite-loading": "^2.4.4", "vue-infinite-loading": "^2.4.4",
"vue-masonry-css": "^1.0.3",
"vue-material-design-icons": "^5.0.0", "vue-material-design-icons": "^5.0.0",
"vue-router": "^3.6.5", "vue-router": "^3.6.5",
"vue-tribute": "^1.0.7", "vue-tribute": "^1.0.7",
@ -47,6 +49,7 @@
"@nextcloud/eslint-config": "^8.2.0", "@nextcloud/eslint-config": "^8.2.0",
"@nextcloud/stylelint-config": "^2.3.0", "@nextcloud/stylelint-config": "^2.3.0",
"@nextcloud/webpack-vue-config": "^5.4.0", "@nextcloud/webpack-vue-config": "^5.4.0",
"copy-webpack-plugin": "^11.0.0",
"cypress": "^11.2.0", "cypress": "^11.2.0",
"jest": "^29.3.1", "jest": "^29.3.1",
"jest-serializer-vue": "^3.1.0", "jest-serializer-vue": "^3.1.0",
@ -3513,7 +3516,6 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "2.0.5", "@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9" "run-parallel": "^1.1.9"
@ -3527,7 +3529,6 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
} }
@ -3537,7 +3538,6 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@nodelib/fs.scandir": "2.1.5", "@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0" "fastq": "^1.6.0"
@ -3796,8 +3796,7 @@
"version": "7.0.11", "version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/@types/json5": { "node_modules/@types/json5": {
"version": "0.0.29", "version": "0.0.29",
@ -4798,7 +4797,6 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"ajv": "^8.0.0" "ajv": "^8.0.0"
}, },
@ -4816,7 +4814,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0", "json-schema-traverse": "^1.0.0",
@ -4832,8 +4829,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/ajv-keywords": { "node_modules/ajv-keywords": {
"version": "3.5.2", "version": "3.5.2",
@ -5585,6 +5581,11 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"dev": true "dev": true
}, },
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="
},
"node_modules/bn.js": { "node_modules/bn.js": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@ -6779,6 +6780,126 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/copy-webpack-plugin": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz",
"integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==",
"dev": true,
"dependencies": {
"fast-glob": "^3.2.11",
"glob-parent": "^6.0.1",
"globby": "^13.1.1",
"normalize-path": "^3.0.0",
"schema-utils": "^4.0.0",
"serialize-javascript": "^6.0.0"
},
"engines": {
"node": ">= 14.15.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
}
},
"node_modules/copy-webpack-plugin/node_modules/ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"dev": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/copy-webpack-plugin/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"ajv": "^8.8.2"
}
},
"node_modules/copy-webpack-plugin/node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/copy-webpack-plugin/node_modules/globby": {
"version": "13.1.3",
"resolved": "https://registry.npmjs.org/globby/-/globby-13.1.3.tgz",
"integrity": "sha512-8krCNHXvlCgHDpegPzleMq07yMYTO2sXKASmZmquEYWEmCx6J5UTRbp5RwMJkTJGtcQ44YpiUYUiN0b9mzy8Bw==",
"dev": true,
"dependencies": {
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.11",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
"node_modules/copy-webpack-plugin/node_modules/schema-utils": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz",
"integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==",
"dev": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.8.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.0.0"
},
"engines": {
"node": ">= 12.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/copy-webpack-plugin/node_modules/slash": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
"integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.25.5", "version": "3.25.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz",
@ -7558,7 +7679,6 @@
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"path-type": "^4.0.0" "path-type": "^4.0.0"
}, },
@ -9189,15 +9309,13 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.2.11", "version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
"integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3", "@nodelib/fs.walk": "^1.2.3",
@ -9258,7 +9376,6 @@
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
"integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
@ -10219,7 +10336,6 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">= 4" "node": ">= 4"
} }
@ -13565,7 +13681,6 @@
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">= 8" "node": ">= 8"
} }
@ -14425,7 +14540,6 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -14899,8 +15013,7 @@
"type": "consulting", "type": "consulting",
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ]
"peer": true
}, },
"node_modules/quick-lru": { "node_modules/quick-lru": {
"version": "4.0.1", "version": "4.0.1",
@ -15298,7 +15411,6 @@
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -15405,7 +15517,6 @@
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"iojs": ">=1.0.0", "iojs": ">=1.0.0",
"node": ">=0.10.0" "node": ">=0.10.0"
@ -15461,7 +15572,6 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
@ -15633,7 +15743,6 @@
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
"integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"randombytes": "^2.1.0" "randombytes": "^2.1.0"
} }
@ -17410,7 +17519,6 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
"integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
@ -17582,11 +17690,6 @@
"tinycolor2": "^1.1.2" "tinycolor2": "^1.1.2"
} }
}, },
"node_modules/vue-contenteditable-directive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/vue-contenteditable-directive/-/vue-contenteditable-directive-1.2.0.tgz",
"integrity": "sha512-9RuW1cboQBOUhURXiQpBD8XldyK2BYWhkWTnRw4Qmv8ZeQy+tGnnPs4XfemoPNf4KQW31Mx6UqEszlZYgoPeYw=="
},
"node_modules/vue-eslint-parser": { "node_modules/vue-eslint-parser": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.1.0.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.1.0.tgz",
@ -17748,6 +17851,11 @@
} }
} }
}, },
"node_modules/vue-masonry-css": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/vue-masonry-css/-/vue-masonry-css-1.0.3.tgz",
"integrity": "sha512-viecHQiHVLez7HlYUQsv1wJb2MT/RDSzkDp6m3In41vPrk6OsBmT2qRE8LZqYIA4daIwrnx/Xm8h4fjOpuE3hw=="
},
"node_modules/vue-material-design-icons": { "node_modules/vue-material-design-icons": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.1.2.tgz", "resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.1.2.tgz",
@ -21213,7 +21321,6 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"@nodelib/fs.stat": "2.0.5", "@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9" "run-parallel": "^1.1.9"
@ -21223,15 +21330,13 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true, "dev": true
"peer": true
}, },
"@nodelib/fs.walk": { "@nodelib/fs.walk": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"@nodelib/fs.scandir": "2.1.5", "@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0" "fastq": "^1.6.0"
@ -21479,8 +21584,7 @@
"version": "7.0.11", "version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"dev": true, "dev": true
"peer": true
}, },
"@types/json5": { "@types/json5": {
"version": "0.0.29", "version": "0.0.29",
@ -22279,7 +22383,6 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"ajv": "^8.0.0" "ajv": "^8.0.0"
}, },
@ -22289,7 +22392,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0", "json-schema-traverse": "^1.0.0",
@ -22301,8 +22403,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true, "dev": true
"peer": true
} }
} }
}, },
@ -22878,6 +22979,11 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"dev": true "dev": true
}, },
"blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="
},
"bn.js": { "bn.js": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@ -23839,6 +23945,89 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"copy-webpack-plugin": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz",
"integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==",
"dev": true,
"requires": {
"fast-glob": "^3.2.11",
"glob-parent": "^6.0.1",
"globby": "^13.1.1",
"normalize-path": "^3.0.0",
"schema-utils": "^4.0.0",
"serialize-javascript": "^6.0.0"
},
"dependencies": {
"ajv": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
}
},
"ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.3"
}
},
"glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"requires": {
"is-glob": "^4.0.3"
}
},
"globby": {
"version": "13.1.3",
"resolved": "https://registry.npmjs.org/globby/-/globby-13.1.3.tgz",
"integrity": "sha512-8krCNHXvlCgHDpegPzleMq07yMYTO2sXKASmZmquEYWEmCx6J5UTRbp5RwMJkTJGtcQ44YpiUYUiN0b9mzy8Bw==",
"dev": true,
"requires": {
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.11",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^4.0.0"
}
},
"json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
},
"schema-utils": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz",
"integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.8.0",
"ajv-formats": "^2.1.1",
"ajv-keywords": "^5.0.0"
}
},
"slash": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
"integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
"dev": true
}
}
},
"core-js": { "core-js": {
"version": "3.25.5", "version": "3.25.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.5.tgz",
@ -24440,7 +24629,6 @@
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"path-type": "^4.0.0" "path-type": "^4.0.0"
} }
@ -25677,15 +25865,13 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true, "dev": true
"peer": true
}, },
"fast-glob": { "fast-glob": {
"version": "3.2.11", "version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
"integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3", "@nodelib/fs.walk": "^1.2.3",
@ -25733,7 +25919,6 @@
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
"integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
@ -26452,8 +26637,7 @@
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
"dev": true, "dev": true
"peer": true
}, },
"immutable": { "immutable": {
"version": "4.1.0", "version": "4.1.0",
@ -28954,8 +29138,7 @@
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true, "dev": true
"peer": true
}, },
"methods": { "methods": {
"version": "1.1.2", "version": "1.1.2",
@ -29617,8 +29800,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true, "dev": true
"peer": true
}, },
"pbkdf2": { "pbkdf2": {
"version": "3.1.2", "version": "3.1.2",
@ -29964,8 +30146,7 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true, "dev": true
"peer": true
}, },
"quick-lru": { "quick-lru": {
"version": "4.0.1", "version": "4.0.1",
@ -30288,8 +30469,7 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true, "dev": true
"peer": true
}, },
"requireindex": { "requireindex": {
"version": "1.2.0", "version": "1.2.0",
@ -30367,8 +30547,7 @@
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true, "dev": true
"peer": true
}, },
"rfdc": { "rfdc": {
"version": "1.3.0", "version": "1.3.0",
@ -30400,7 +30579,6 @@
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
@ -30526,7 +30704,6 @@
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
"integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"randombytes": "^2.1.0" "randombytes": "^2.1.0"
} }
@ -31912,7 +32089,6 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
"integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
@ -32067,11 +32243,6 @@
"tinycolor2": "^1.1.2" "tinycolor2": "^1.1.2"
} }
}, },
"vue-contenteditable-directive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/vue-contenteditable-directive/-/vue-contenteditable-directive-1.2.0.tgz",
"integrity": "sha512-9RuW1cboQBOUhURXiQpBD8XldyK2BYWhkWTnRw4Qmv8ZeQy+tGnnPs4XfemoPNf4KQW31Mx6UqEszlZYgoPeYw=="
},
"vue-eslint-parser": { "vue-eslint-parser": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.1.0.tgz", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.1.0.tgz",
@ -32186,6 +32357,11 @@
"vue-style-loader": "^4.1.0" "vue-style-loader": "^4.1.0"
} }
}, },
"vue-masonry-css": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/vue-masonry-css/-/vue-masonry-css-1.0.3.tgz",
"integrity": "sha512-viecHQiHVLez7HlYUQsv1wJb2MT/RDSzkDp6m3In41vPrk6OsBmT2qRE8LZqYIA4daIwrnx/Xm8h4fjOpuE3hw=="
},
"vue-material-design-icons": { "vue-material-design-icons": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.1.2.tgz", "resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.1.2.tgz",

Wyświetl plik

@ -42,6 +42,8 @@
"@nextcloud/router": "^2.0.1", "@nextcloud/router": "^2.0.1",
"@nextcloud/vue": "^7.4.0", "@nextcloud/vue": "^7.4.0",
"@nextcloud/vue-richtext": "^2.0.4", "@nextcloud/vue-richtext": "^2.0.4",
"blurhash": "^2.0.5",
"debounce": "^1.2.1",
"he": "^1.2.0", "he": "^1.2.0",
"linkify-plugin-mention": "^4.1.0", "linkify-plugin-mention": "^4.1.0",
"linkify-string": "^4.1.0", "linkify-string": "^4.1.0",
@ -53,8 +55,8 @@
"v-tooltip": "^4.0.0-beta.0", "v-tooltip": "^4.0.0-beta.0",
"vue": "^2.7.10", "vue": "^2.7.10",
"vue-click-outside": "^1.0.7", "vue-click-outside": "^1.0.7",
"vue-contenteditable-directive": "^1.2.0",
"vue-infinite-loading": "^2.4.4", "vue-infinite-loading": "^2.4.4",
"vue-masonry-css": "^1.0.3",
"vue-material-design-icons": "^5.0.0", "vue-material-design-icons": "^5.0.0",
"vue-router": "^3.6.5", "vue-router": "^3.6.5",
"vue-tribute": "^1.0.7", "vue-tribute": "^1.0.7",
@ -80,6 +82,7 @@
"@nextcloud/eslint-config": "^8.2.0", "@nextcloud/eslint-config": "^8.2.0",
"@nextcloud/stylelint-config": "^2.3.0", "@nextcloud/stylelint-config": "^2.3.0",
"@nextcloud/webpack-vue-config": "^5.4.0", "@nextcloud/webpack-vue-config": "^5.4.0",
"copy-webpack-plugin": "^11.0.0",
"cypress": "^11.2.0", "cypress": "^11.2.0",
"jest": "^29.3.1", "jest": "^29.3.1",
"jest-serializer-vue": "^3.1.0", "jest-serializer-vue": "^3.1.0",

Wyświetl plik

@ -106,9 +106,11 @@ export default {
} }
}, },
computed: { computed: {
/** @return {import('vue').PropType<import('../types/Mastodon.js').Account>} */
timeline() { timeline() {
return this.$store.getters.getTimeline return this.$store.getters.getTimeline
}, },
/** @return {{items: {id: string, icon: object, title: string, to: { name: string } }, loading: boolean}} */
menu() { menu() {
const defaultCategories = [ const defaultCategories = [
{ {
@ -152,7 +154,7 @@ export default {
title: t('social', 'Liked'), title: t('social', 'Liked'),
to: { to: {
name: 'timeline', name: 'timeline',
params: { type: 'liked' }, params: { type: 'favourites' },
}, },
}, },
{ {
@ -263,3 +265,10 @@ export default {
} }
</style> </style>
<style lang="css">
img.emoji {
margin: 3px;
width: 16px;
vertical-align: text-bottom;
}
</style>

Wyświetl plik

@ -21,10 +21,10 @@
--> -->
<template> <template>
<NcAvatar v-if="actor.local" <NcAvatar v-if="isLocal"
:size="size" :size="size"
:user="actor.preferredUsername" :user="actor.username"
:display-name="actor.account" :display-name="actor.acct"
:disable-tooltip="true" :disable-tooltip="true"
:show-user-status="false" /> :show-user-status="false" />
<NcAvatar v-else <NcAvatar v-else
@ -44,8 +44,15 @@ export default {
NcAvatar, NcAvatar,
}, },
props: { props: {
actor: { type: Object, default: () => {} }, /** @type {import('vue').PropType<import('../types/Mastodon.js').Account>} */
size: { type: Number, default: 32 }, actor: {
type: Object,
default: () => {},
},
size: {
type: Number,
default: 32,
},
}, },
data() { data() {
return { return {
@ -53,9 +60,16 @@ export default {
} }
}, },
computed: { computed: {
/** @return {string} */
avatarUrl() { avatarUrl() {
return generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.item.attributedTo) return generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.item.attributedTo)
}, },
/**
* @return {boolean}
*/
isLocal() {
return !this.actor.acct.includes('@')
},
}, },
} }
</script> </script>

Wyświetl plik

@ -26,6 +26,8 @@
<input id="file-upload" <input id="file-upload"
ref="fileUploadInput" ref="fileUploadInput"
type="file" type="file"
accept="image/*"
multiple="true"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
class="hidden-visually" class="hidden-visually"
@ -47,8 +49,8 @@
<div v-if="replyTo" class="reply-to"> <div v-if="replyTo" class="reply-to">
<p class="reply-info"> <p class="reply-info">
<span>{{ t('social', 'In reply to') }}</span> <span>{{ t('social', 'In reply to') }}</span>
<ActorAvatar :actor="replyTo.actor_info" :size="16" /> <ActorAvatar :actor="replyTo.account" :size="16" />
<strong>{{ replyTo.actor_info.account }}</strong> <strong>{{ replyTo.account.acct }}</strong>
<NcButton type="tertiary" <NcButton type="tertiary"
class="close-button" class="close-button"
:aria-label="t('social', 'Close reply')" :aria-label="t('social', 'Close reply')"
@ -64,25 +66,24 @@
</div> </div>
<form class="new-post-form" @submit.prevent="createPost"> <form class="new-post-form" @submit.prevent="createPost">
<VueTribute :options="tributeOptions"> <VueTribute :options="tributeOptions">
<!-- eslint-disable-next-line vue/valid-v-model -->
<div ref="composerInput" <div ref="composerInput"
v-contenteditable:post.dangerousHTML="canType && !loading" :disabled="loading"
class="message" class="message"
placeholder="What would you like to share?" placeholder="What would you like to share?"
:class="{'icon-loading': loading}" :class="{'icon-loading': loading}"
@keyup.prevent.enter="keyup" @keyup.prevent.enter="keyup"
@input="updateStatusContent"
@tribute-replaced="updatePostFromTribute" /> @tribute-replaced="updatePostFromTribute" />
</VueTribute> </VueTribute>
<PreviewGrid :uploading="false" <PreviewGrid :uploading="false"
:upload-progress="0.4" :upload-progress="0.4"
:miniatures="previewUrls" :miniatures="attachments"
@deleted="deletePreview" /> @deleted="deletePreview" />
<div class="options"> <div class="options">
<NcButton v-tooltip="t('social', 'Add attachment')" <NcButton v-tooltip="t('social', 'Add attachment')"
type="tertiary" type="tertiary"
:disabled="previewUrls.length >= 1"
:aria-label="t('social', 'Add attachment')" :aria-label="t('social', 'Add attachment')"
@click.prevent="clickImportInput"> @click.prevent="clickImportInput">
<template #icon> <template #icon>
@ -94,7 +95,7 @@
<NcEmojiPicker ref="emojiPicker" <NcEmojiPicker ref="emojiPicker"
:search="search" :search="search"
:close-on-select="false" :close-on-select="false"
:container="container" container="#content-vue"
@select="insert"> @select="insert">
<NcButton v-tooltip="t('social', 'Add emoji')" <NcButton v-tooltip="t('social', 'Add emoji')"
type="tertiary" type="tertiary"
@ -107,18 +108,11 @@
</NcEmojiPicker> </NcEmojiPicker>
</div> </div>
<div v-click-outside="hidePopoverMenu" class="popovermenu-parent"> <VisibilitySelect :type.sync="type" />
<NcButton v-tooltip="t('social', 'Visibility')"
type="tertiary"
:class="currentVisibilityIconClass"
@click.prevent="togglePopoverMenu" />
<div :class="{open: menuOpened}" class="popovermenu">
<NcPopoverMenu :menu="visibilityPopover" />
</div>
</div>
<div class="emptySpace" /> <div class="emptySpace" />
<NcButton :value="currentVisibilityPostLabel" <SubmitStatusButton :type="type" :disabled="canPost || loading" @click="createPost" />
<!-- <NcButton :value="currentVisibilityPostLabel"
:disabled="!canPost" :disabled="!canPost"
native-type="submit" native-type="submit"
type="primary" type="primary"
@ -127,7 +121,7 @@
<Send title="" :size="22" decorative /> <Send title="" :size="22" decorative />
</template> </template>
{{ postTo }} {{ postTo }}
</NcButton> </NcButton> -->
</div> </div>
</form> </form>
</div> </div>
@ -136,12 +130,11 @@
<script> <script>
import EmoticonOutline from 'vue-material-design-icons/EmoticonOutline.vue' import EmoticonOutline from 'vue-material-design-icons/EmoticonOutline.vue'
import Send from 'vue-material-design-icons/Send.vue'
import Close from 'vue-material-design-icons/Close.vue' import Close from 'vue-material-design-icons/Close.vue'
import FileUpload from 'vue-material-design-icons/FileUpload.vue' import FileUpload from 'vue-material-design-icons/FileUpload.vue'
import debounce from 'debounce'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcPopoverMenu from '@nextcloud/vue/dist/Components/NcPopoverMenu.js'
import NcEmojiPicker from '@nextcloud/vue/dist/Components/NcEmojiPicker.js' import NcEmojiPicker from '@nextcloud/vue/dist/Components/NcEmojiPicker.js'
import VueTribute from 'vue-tribute' import VueTribute from 'vue-tribute'
import he from 'he' import he from 'he'
@ -151,11 +144,18 @@ import axios from '@nextcloud/axios'
import ActorAvatar from '../ActorAvatar.vue' import ActorAvatar from '../ActorAvatar.vue'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import PreviewGrid from './PreviewGrid.vue' import PreviewGrid from './PreviewGrid.vue'
import VisibilitySelect from './VisibilitySelect.vue'
import SubmitStatusButton from './SubmitStatusButton.vue'
/**
* @typedef LocalAttachment
* @property {File} file - The file object from the input element.
* @property {import('../../types/Mastodon.js').MediaAttachment} data - The attachment information from the server.
*/
export default { export default {
name: 'Composer', name: 'Composer',
components: { components: {
NcPopoverMenu,
NcAvatar, NcAvatar,
NcEmojiPicker, NcEmojiPicker,
NcButton, NcButton,
@ -163,25 +163,24 @@ export default {
FileUpload, FileUpload,
VueTribute, VueTribute,
EmoticonOutline, EmoticonOutline,
Send,
Close, Close,
PreviewGrid, PreviewGrid,
VisibilitySelect,
SubmitStatusButton,
}, },
directives: { directives: {
FocusOnCreate, FocusOnCreate,
}, },
mixins: [CurrentUserMixin], mixins: [CurrentUserMixin],
props: {},
data() { data() {
return { return {
statusContent: '',
type: localStorage.getItem('social.lastPostType') || 'followers', type: localStorage.getItem('social.lastPostType') || 'followers',
loading: false, loading: false,
post: '', /** @type {Object<string, LocalAttachment>} */
miniatures: [], // miniatures of images stored in postAttachments attachments: {},
postAttachments: [], // The toot's attachments
previewUrls: [],
canType: true,
search: '', search: '',
/** @type {import('../../types/Mastodon.js').Status} */
replyTo: null, replyTo: null,
tributeOptions: { tributeOptions: {
spaceSelectsMatch: true, spaceSelectsMatch: true,
@ -201,25 +200,23 @@ export default {
return '<span class="mention" contenteditable="false">' return '<span class="mention" contenteditable="false">'
+ '<a href="' + item.original.url + '" target="_blank"><img src="' + item.original.avatar + '" />@' + item.original.value + '</a></span>' + '<a href="' + item.original.url + '" target="_blank"><img src="' + item.original.avatar + '" />@' + item.original.value + '</a></span>'
}, },
values: (text, cb) => { values: debounce(async (text, populate) => {
const users = []
if (text.length < 1) { if (text.length < 1) {
cb(users) populate([])
} }
this.remoteSearchAccounts(text).then((result) => {
for (const i in result.data.result.accounts) { const response = await this.remoteSearchAccounts(text)
const user = result.data.result.accounts[i]
users.push({ const users = response.data.result.accounts.map((user) => ({
key: user.preferredUsername, key: user.preferredUsername,
value: user.account, value: user.account,
url: user.url, url: user.url,
avatar: user.local ? generateUrl(`/avatar/${user.preferredUsername}/32`) : generateUrl(`apps/social/api/v1/global/actor/avatar?id=${user.id}`), avatar: user.local ? generateUrl(`/avatar/${user.preferredUsername}/32`) : generateUrl(`apps/social/api/v1/global/actor/avatar?id=${user.id}`),
}) }))
}
cb(users) console.debug('[Composer] Found users for', text, response.data.result, users)
}) populate(users)
}, }, 200),
}, },
{ {
trigger: '#', trigger: '#',
@ -237,29 +234,20 @@ export default {
return '<span class="hashtag" contenteditable="false">' return '<span class="hashtag" contenteditable="false">'
+ '<a href="' + generateUrl('/timeline/tags/' + tag) + '" target="_blank">#' + tag + '</a></span>' + '<a href="' + generateUrl('/timeline/tags/' + tag) + '" target="_blank">#' + tag + '</a></span>'
}, },
values: (text, cb) => { values: debounce(async (text, populate) => {
const tags = []
if (text.length < 1) { if (text.length < 1) {
cb(tags) populate([])
} }
this.remoteSearchHashtags(text).then((result) => {
if (result.data.result.exact) { const response = await this.remoteSearchHashtags(text)
tags.push({ const tags = [
key: result.data.result.exact, ...(response.data.result.exact && !Array.isArray(response.data.result.exact) ? [{ key: response.data.result.exact, value: response.data.result.exact }] : []),
value: result.data.result.exact, ...response.data.result.tags.map(({ hashtag }) => ({ key: hashtag, value: hashtag })),
}) ]
}
for (const i in result.data.result.tags) { console.debug('[Composer] Found tags for', text, response.data.result, tags)
const tag = result.data.result.tags[i] populate(tags)
tags.push({ }, 200),
key: tag.hashtag,
value: tag.hashtag,
})
}
cb(tags)
})
},
}, },
], ],
noMatchTemplate() { noMatchTemplate() {
@ -272,123 +260,15 @@ export default {
} }
}, },
}, },
menuOpened: false,
} }
}, },
computed: { computed: {
postTo() { /** @return {boolean} */
switch (this.type) {
case 'public':
case 'unlisted':
return t('social', 'Post')
case 'followers':
return t('social', 'Post to followers')
case 'direct':
return t('social', 'Post to mentioned users')
}
return ''
},
currentVisibilityIconClass() {
return this.visibilityIconClass(this.type)
},
visibilityIconClass() {
return (type) => {
if (typeof type === 'undefined') {
type = this.type
}
switch (type) {
case 'public':
return 'icon-link'
case 'followers':
return 'icon-contacts-dark'
case 'direct':
return 'icon-external'
case 'unlisted':
return 'icon-password'
}
}
},
currentVisibilityPostLabel() {
return this.visibilityPostLabel(this.type)
},
visibilityPostLabel() {
return (type) => {
if (typeof type === 'undefined') {
type = this.type
}
switch (type) {
case 'public':
return t('social', 'Post publicly')
case 'followers':
return t('social', 'Post to followers')
case 'direct':
return t('social', 'Post to recipients')
case 'unlisted':
return t('social', 'Post unlisted')
}
}
},
activeState() {
return (type) => {
if (type === this.type) {
return true
} else {
return false
}
}
},
visibilityPopover() {
return [
{
action: () => {
this.switchType('public')
},
icon: this.visibilityIconClass('public'),
active: this.activeState('public'),
text: t('social', 'Public'),
longtext: t('social', 'Post to public timelines'),
},
{
action: () => {
this.switchType('unlisted')
},
icon: this.visibilityIconClass('unlisted'),
active: this.activeState('unlisted'),
text: t('social', 'Unlisted'),
longtext: t('social', 'Do not post to public timelines'),
},
{
action: () => {
this.switchType('followers')
},
icon: this.visibilityIconClass('followers'),
active: this.activeState('followers'),
text: t('social', 'Followers'),
longtext: t('social', 'Post to followers only'),
},
{
action: () => {
this.switchType('direct')
},
icon: this.visibilityIconClass('direct'),
active: this.activeState('direct'),
text: t('social', 'Direct'),
longtext: t('social', 'Post to mentioned users only'),
},
]
},
container() {
return '#content-vue'
},
containerElement() {
return document.querySelector(this.container)
},
canPost() { canPost() {
if (this.previewUrls.length > 0) { if (Object.keys(this.attachments).length > 0) {
return true return true
} }
return this.post.length !== 0 && this.post !== '<br>' return this.statusContent.length !== 0 && this.statusContent !== '<br>'
}, },
}, },
mounted() { mounted() {
@ -398,95 +278,63 @@ export default {
}) })
}, },
methods: { methods: {
updateStatusContent() {
this.statusContent = this.$refs.composerInput.innerHTML
},
clickImportInput() { clickImportInput() {
this.$refs.fileUploadInput.click() this.$refs.fileUploadInput.click()
}, },
/** @param {InputEvent} event */
handleFileChange(event) { handleFileChange(event) {
event.target.files.forEach((file) => { /** @type {HTMLInputElement} */
this.previewUrls.push({ const target = event.target
description: '', Array.from(target.files).forEach(async (file) => {
url: URL.createObjectURL(file), const url = URL.createObjectURL(file)
result: file, this.$set(this.attachments, url, {
file,
data: null,
}) })
this.$set(this.attachments[url], 'data', await this.$store.dispatch('createMedia', file))
}) })
}, },
removeAttachment(idx) {
this.previewUrls.splice(idx, 1)
},
insert(emoji) { insert(emoji) {
console.debug('[Composer] insert emoji', emoji)
if (typeof emoji === 'object') { if (typeof emoji === 'object') {
const category = Object.keys(emoji)[0] const category = Object.keys(emoji)[0]
const emojis = emoji[category] const emojis = emoji[category]
const firstEmoji = Object.keys(emojis)[0] const firstEmoji = Object.keys(emojis)[0]
emoji = emojis[firstEmoji] emoji = emojis[firstEmoji]
} }
this.post += this.$twemoji.parse(emoji) + ' '
this.$refs.composerInput.innerHTML += this.$twemoji.parse(emoji) + ' '
},
togglePopoverMenu() {
this.menuOpened = !this.menuOpened
},
hidePopoverMenu() {
this.menuOpened = false
},
switchType(type) {
this.type = type
this.menuOpened = false
localStorage.setItem('social.lastPostType', type)
},
getPostData() {
const element = this.$refs.composerInput.cloneNode(true)
Array.from(element.getElementsByClassName('emoji')).forEach((emoji) => {
const em = document.createTextNode(emoji.getAttribute('alt'))
emoji.replaceWith(em)
})
const contentHtml = element.innerHTML /** @type {Element} */
const lastChild = this.$refs.composerInput.lastChild
const div = document.createElement('div')
div.innerHTML = this.$twemoji.parse(emoji) + ' '
// Extract mentions from content and create an array out of them if (lastChild === null) {
const to = [] this.$refs.composerInput.innerHTML = div.innerHTML
const mentionRegex = /<span class="mention"[^>]+><a[^>]+><img[^>]+>@([\w-_.]+@[\w-.]+)/g } else {
let match = null
do { // Content usually ends with </br> or </>
match = mentionRegex.exec(contentHtml) // This makes sure that we put the emoji before those tags.
if (match) { switch (lastChild.tagName) {
to.push(match[1]) case 'BR':
lastChild.before(div.firstChild)
break
case 'DIV':
switch (lastChild.lastChild.tagName) {
case 'BR':
lastChild.lastChild.before(div.firstChild)
break
default:
lastChild.append(div.firstChild)
}
break
default:
lastChild.after(div.firstChild)
} }
} while (match)
// Add author of original post in case of reply
if (this.replyTo !== null) {
to.push(this.replyTo.actor_info.account)
} }
this.updateStatusContent()
// Extract hashtags from content and create an array ot of them
const hashtagRegex = />#([^<]+)</g
const hashtags = []
match = null
do {
match = hashtagRegex.exec(contentHtml)
if (match) {
hashtags.push(match[1])
}
} while (match)
// Remove all html tags but </div> (wich we turn in newlines) and decode the remaining html entities
let content = contentHtml.replace(/<(?!\/div)[^>]+>/gi, '').replace(/<\/div>/gi, '\n').trim()
content = he.decode(content)
const formData = new FormData()
formData.append('content', content)
to.forEach(to => formData.append('to[]', to))
hashtags.forEach(hashtag => formData.append('hashtags[]', hashtag))
formData.append('type', this.type)
this.previewUrls.forEach(preview => formData.append('attachments[]', preview.result))
this.previewUrls.forEach(preview => formData.append('attachmentDescriptions[]', preview.description))
if (this.replyTo) {
formData.append('replyTo', this.replyTo.id)
}
return formData
}, },
keyup(event) { keyup(event) {
if (event.shiftKey || event.ctrlKey) { if (event.shiftKey || event.ctrlKey) {
@ -494,45 +342,44 @@ export default {
} }
}, },
updatePostFromTribute(event) { updatePostFromTribute(event) {
// Trick to let vue-contenteditable know that tribute replaced a mention or hashtag console.debug('[Composer] update from tribute', event)
this.$refs.composerInput.oninput(event) this.updateStatusContent()
}, },
async createPost(event) { async createPost(event) {
// Replace emoji <img> tag with actual emojis.
const postData = this.getPostData() // They will be replaced again with twemoji during rendering
const element = this.$refs.composerInput.cloneNode(true)
// Trick to validate last mention when the user directly clicks on the "post" button without validating it. Array.from(element.getElementsByClassName('emoji')).forEach((emoji) => {
const regex = /@([-\w]+)$/ const em = document.createTextNode(emoji.getAttribute('alt'))
const lastMention = postData.get('content').match(regex) emoji.replaceWith(em)
if (lastMention) {
// Ask the server for matching accounts, and wait for the results
const result = await this.remoteSearchAccounts(lastMention[1])
// Validate the last mention only when it matches a single account
if (result.data.result.accounts.length === 1) {
postData.set('content', postData.get('content').replace(regex, '@' + result.data.result.accounts[0].account))
postData.set('to', postData.get('to').push(result.data.result.accounts[0].account))
}
}
// Abort if the post is a direct message and no valid mentions were found
// if (this.type === 'direct' && postData.get('to').length === 0) {
// OC.Notification.showTemporary(t('social', 'Error while trying to post your message: Could not find any valid recipients.'), { type: 'error' })
// return
// }
// Post message
this.loading = true
this.$store.dispatch('post', postData).then((response) => {
this.loading = false
this.replyTo = null
this.post = ''
this.$refs.composerInput.innerText = this.post
this.previewUrls = []
this.$store.dispatch('refreshTimeline')
}) })
let status = element.innerHTML.replace(/<(?!\/div)[^>]+>/gi, '').replace(/<\/div>/gi, '\n').trim()
status = he.decode(status)
const statusData = {
content_type: '',
media_ids: Object.values(this.attachments).map(preview => preview.data.id),
sensitive: false,
spoiler_text: '',
status,
in_reply_to_id: this.replyTo?.id,
visibility: this.type,
}
console.debug('[Composer] Posting status', statusData)
// Post message
try {
this.loading = true
await this.$store.dispatch('post', statusData)
} finally {
this.loading = false
this.replyTo = null
this.$refs.composerInput.innerText = ''
this.attachments = {}
this.$store.dispatch('refreshTimeline')
}
}, },
closeReply() { closeReply() {
this.replyTo = null this.replyTo = null
@ -540,13 +387,13 @@ export default {
this.$store.commit('setComposerDisplayStatus', false) this.$store.commit('setComposerDisplayStatus', false)
}, },
remoteSearchAccounts(text) { remoteSearchAccounts(text) {
return axios.get(generateUrl('apps/social/api/v1/global/accounts/search?search=' + text)) return axios.get(generateUrl('apps/social/api/v1/global/accounts/search'), { params: { search: text } })
}, },
remoteSearchHashtags(text) { remoteSearchHashtags(text) {
return axios.get(generateUrl('apps/social/api/v1/global/tags/search?search=' + text)) return axios.get(generateUrl('apps/social/api/v1/global/tags/search'), { params: { search: text } })
}, },
deletePreview(index) { deletePreview(key) {
this.previewUrls.splice(index, 1) this.$delete(this.attachments, key)
}, },
}, },
} }

Wyświetl plik

@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</div> </div>
</div> </div>
<div class="preview-grid"> <div class="preview-grid">
<PreviewGridItem v-for="(item, index) in miniatures" <PreviewGridItem v-for="(item, randomKey) in miniatures"
:key="index" :key="randomKey"
:preview="item" :preview="item"
:index="index" :random-key="randomKey"
@delete="deletePreview" /> @delete="deletePreview" />
</div> </div>
</div> </div>
@ -31,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<script> <script>
import PreviewGridItem from './PreviewGridItem.vue' import PreviewGridItem from './PreviewGridItem.vue'
import FileUpload from 'vue-material-design-icons/FileUpload.vue' import FileUpload from 'vue-material-design-icons/FileUpload.vue'
import { translate } from '@nextcloud/l10n'
export default { export default {
name: 'PreviewGrid', name: 'PreviewGrid',
@ -47,15 +48,18 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
/** @type {import('vue').PropType<Object<string, import('./Composer.vue').LocalAttachment>>} */
miniatures: { miniatures: {
type: Array, type: Object,
required: true, required: true,
}, },
}, },
methods: { methods: {
deletePreview(index) { deletePreview(randomKey) {
this.$emit('deleted', index) this.$emit('deleted', randomKey)
}, },
t: translate,
}, },
} }
</script> </script>

Wyświetl plik

@ -1,87 +1,46 @@
<template> <template>
<div class="preview-item-wrapper"> <div class="preview-item-wrapper">
<div class="preview-item" :style="backgroundStyle"> <div class="preview-item">
<MediaAttachment :attachment="preview.data" />
<div class="preview-item__actions"> <div class="preview-item__actions">
<NcButton type="tertiary-no-background" @click="$emit('delete', index)"> <NcButton type="tertiary-no-background" @click="$emit('delete', randomKey)">
<template #icon> <template #icon>
<Close :size="16" fill-color="white" /> <Close :size="16" fill-color="white" />
</template> </template>
<span>{{ t('social', 'Delete') }}</span> <span>{{ t('social', 'Delete') }}</span>
</NcButton> </NcButton>
<!--
<NcButton type="tertiary-no-background" @click="showModal">
<template #icon>
<Edit :size="16" fill-color="white" />
</template>
<span>{{ t('social', 'Edit') }}</span>
</NcButton>
-->
</div> </div>
<!--
<div v-if="preview.description.length === 0" class="description-warning">
{{ t('social', 'No description added') }}
</div>
<NcModal v-if="modal" size="small" @close="closeModal">
<div class="modal__content">
<label :for="`image-description-${index}`">
{{ t('social', 'Describe for the visually impaired') }}
</label>
<textarea :id="`image-description-${index}`" v-model="preview.description" />
<NcButton type="primary" @click="closeModal">
{{ t('social', 'Close') }}
</NcButton>
</div>
</NcModal>
-->
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import Close from 'vue-material-design-icons/Close.vue' import Close from 'vue-material-design-icons/Close.vue'
// import Edit from 'vue-material-design-icons/Pencil.vue'
// import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import { translate } from '@nextcloud/l10n'
import MediaAttachment from '../MediaAttachment.vue'
export default { export default {
name: 'PreviewGridItem', name: 'PreviewGridItem',
components: { components: {
Close, Close,
// Edit,
// NcModal,
NcButton, NcButton,
MediaAttachment,
}, },
props: { props: {
/** @type {import('vue').PropType<import('./Composer.vue').LocalAttachment>} */
preview: { preview: {
type: Object, type: Object,
required: true, required: true,
}, },
index: { randomKey: {
type: Number, type: String,
required: true, required: true,
}, },
}, },
data() {
return {
modal: false,
}
},
computed: {
backgroundStyle() {
return {
backgroundImage: `url("${this.preview.url}")`,
}
},
},
methods: { methods: {
showModal() { t: translate,
this.modal = true
},
closeModal() {
this.modal = false
},
}, },
} }
</script> </script>
@ -95,7 +54,7 @@ export default {
.preview-item { .preview-item {
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
background-color: #000; background: var(--color-background-darker);
background-position: 50%; background-position: 50%;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
@ -109,6 +68,9 @@ export default {
} }
&__actions { &__actions {
position: absolute;
top: 0;
width: 100%;
background: linear-gradient(180deg,rgba(0,0,0,.8),rgba(0,0,0,.35) 80%,transparent); background: linear-gradient(180deg,rgba(0,0,0,.8),rgba(0,0,0,.35) 80%,transparent);
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

Wyświetl plik

@ -0,0 +1,137 @@
<!--
- @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- @copyright Copyright (c) 2022 Carl Schwan <carl@carlschwan.eu>
-
- @author Julius Härtl <jus@bitgrid.net>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<NcButton :value="currentVisibilityPostLabel"
:disabled="!disabled"
native-type="submit"
type="primary"
@click.prevent="handleClick">
<template #icon>
<Send title="" :size="22" decorative />
</template>
{{ postTo }}
</NcButton>
</template>
<script>
import Send from 'vue-material-design-icons/Send.vue'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
export default {
name: 'SubmitStatusButton',
components: {
NcButton,
Send,
},
props: {
type: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: true,
},
},
computed: {
/** @return {string} */
postTo() {
switch (this.type) {
case 'public':
case 'unlisted':
return t('social', 'Post')
case 'followers':
return t('social', 'Post to followers')
case 'direct':
return t('social', 'Post to mentioned users')
}
return ''
},
/** @return {string} */
currentVisibilityPostLabel() {
return this.visibilityPostLabel(this.type)
},
/** @return {Function} */
visibilityPostLabel() {
return (type) => {
if (typeof type === 'undefined') {
type = this.type
}
switch (type) {
case 'public':
return t('social', 'Post publicly')
case 'followers':
return t('social', 'Post to followers')
case 'direct':
return t('social', 'Post to recipients')
case 'unlisted':
return t('social', 'Post unlisted')
}
}
},
},
methods: {
handleClick() {
this.$emit('click')
},
},
}
</script>
<style scoped lang="scss">
.new-post {
padding: 10px;
background-color: var(--color-main-background);
position: sticky;
z-index: 100;
margin-bottom: 10px;
top: 0;
&-form {
flex-grow: 1;
position: relative;
top: -10px;
margin-left: 39px;
&__emoji-picker {
z-index: 1;
}
}
}
input[type=submit].inline {
width: 44px;
height: 44px;
margin: 0;
padding: 13px;
background-color: transparent;
border: none;
opacity: 0.3;
position: absolute;
bottom: 0;
right: 0;
}
</style>

Wyświetl plik

@ -0,0 +1,121 @@
<!--
- @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- @copyright Copyright (c) 2022 Carl Schwan <carl@carlschwan.eu>
-
- @author Julius Härtl <jus@bitgrid.net>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div v-click-outside="hidePopoverMenu" class="popovermenu-parent">
<NcButton v-tooltip="t('social', 'Visibility')"
type="tertiary"
:class="currentVisibilityIconClass"
@click.prevent="togglePopoverMenu" />
<div :class="{open: menuOpened}" class="popovermenu">
<NcPopoverMenu :menu="visibilityPopover" />
</div>
</div>
</template>
<script>
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcPopoverMenu from '@nextcloud/vue/dist/Components/NcPopoverMenu.js'
import { translate } from '@nextcloud/l10n'
export default {
name: 'VisibilitySelect',
components: {
NcPopoverMenu,
NcButton,
},
props: {
type: {
type: String,
required: true,
},
},
data() {
return {
menuOpened: false,
test: false,
typeToClass: {
public: 'icon-link',
followers: 'icon-contacts-dark',
direct: 'icon-external',
unlisted: 'icon-password',
},
}
},
computed: {
/** @return {string} */
currentVisibilityIconClass() {
return this.typeToClass[this.type]
},
/** @return {Array} */
visibilityPopover() {
return [
{
action: () => this.switchType('public'),
icon: this.typeToClass.public,
active: this.type === 'public',
text: t('social', 'Public'),
longtext: t('social', 'Post to public timelines'),
},
{
action: () => this.switchType('unlisted'),
icon: this.typeToClass.unlisted,
active: this.type === 'unlisted',
text: t('social', 'Unlisted'),
longtext: t('social', 'Do not post to public timelines'),
},
{
action: () => this.switchType('followers'),
icon: this.typeToClass.followers,
active: this.type === 'followers',
text: t('social', 'Followers'),
longtext: t('social', 'Post to followers only'),
},
{
action: () => this.switchType('direct'),
icon: this.typeToClass.direct,
active: this.type === 'direct',
text: t('social', 'Direct'),
longtext: t('social', 'Post to mentioned users only'),
},
]
},
},
methods: {
togglePopoverMenu() {
this.menuOpened = !this.menuOpened
},
hidePopoverMenu() {
this.menuOpened = false
},
switchType(type) {
this.$emit('update:type', type)
this.menuOpened = false
localStorage.setItem('social.lastPostType', type)
},
t: translate,
},
}
</script>

Wyświetl plik

@ -16,10 +16,10 @@ const UFE0Fg = /\uFE0F/g
export default { export default {
name: 'Emoji', name: 'Emoji',
props: { props: {
emoji: { type: String, default: '' }, emoji: {
}, type: String,
data() { default: '',
return {} },
}, },
computed: { computed: {
/** /**

Wyświetl plik

@ -21,39 +21,47 @@
--> -->
<template> <template>
<div class="emptycontent"> <NcEmptyContent :title="item.title" :description="item.description">
<img v-if="item.image" <template v-if="item.image" #icon>
:src="imageUrl" <img class="empty-content__image"
class="icon-illustration" :src="imageUrl"
alt=""> alt="">
<h2>{{ item.title }}</h2> </template>
<p>{{ item.description }}</p> </NcEmptyContent>
</div>
</template> </template>
<script> <script>
import { linkTo } from '@nextcloud/router'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
export default { export default {
name: 'EmptyContent', name: 'EmptyContent',
components: {
NcEmptyContent,
},
props: { props: {
item: { type: Object, default: () => {} }, item: {
type: Object,
default: () => {},
},
}, },
computed: { computed: {
/** @return {string} */
imageUrl() { imageUrl() {
return OC.linkTo('social', this.item.image) return linkTo('social', this.item.image)
}, },
}, },
} }
</script> </script>
<style scoped> <style scoped>
.emptycontent { .empty-content__image {
margin-top: 5vh; height: 256px;
} width: 256px;
}
.emptycontent .icon-illustration { :deep(.empty-content__icon) {
height: 256px; opacity: 1;
width: 256px; margin-bottom: 90px;
margin: 0; }
opacity: 1;
}
</style> </style>

Wyświetl plik

@ -22,8 +22,8 @@
<template> <template>
<!-- Show button only if user is authenticated and she is not the same as the account viewed --> <!-- Show button only if user is authenticated and she is not the same as the account viewed -->
<div v-if="!serverData.public && accountInfo && accountInfo.viewerLink!='viewer'"> <div v-if="!serverData.public && relationship !== undefined">
<div v-if="isCurrentUserFollowing" <div v-if="relationship.following"
class="follow-button-container"> class="follow-button-container">
<NcButton :disabled="loading" <NcButton :disabled="loading"
class="follow-button follow-button--following" class="follow-button follow-button--following"
@ -87,16 +87,20 @@ export default {
} }
}, },
computed: { computed: {
/** @return {boolean} */
isCurrentUserFollowing() { isCurrentUserFollowing() {
return this.$store.getters.isFollowingUser(this.account) return this.$store.getters.isFollowingUser(this.profileAccount)
},
/** @return {import('../types/Mastodon.js').Account} */
currentAccount() {
return this.$store.getters.currentAccount
}, },
}, },
methods: { methods: {
async follow() { async follow() {
try { try {
this.loading = true this.loading = true
await this.$store.dispatch('followAccount', { currentAccount: this.cloudId, accountToFollow: this.account }) await this.$store.dispatch('followAccount', { currentAccount: this.cloudId, accountToFollow: this.profileAccount })
} catch {
} finally { } finally {
this.loading = false this.loading = false
} }
@ -104,8 +108,7 @@ export default {
async unfollow() { async unfollow() {
try { try {
this.loading = true this.loading = true
await this.$store.dispatch('unfollowAccount', { currentAccount: this.cloudId, accountToUnfollow: this.account }) await this.$store.dispatch('unfollowAccount', { currentAccount: this.cloudId, accountToUnfollow: this.profileAccount })
} catch {
} finally { } finally {
this.loading = false this.loading = false
} }

Wyświetl plik

@ -0,0 +1,77 @@
<template>
<div class="attachment" @click="$emit('click')">
<canvas v-if="!previewLoaded" ref="canvas" class="attachment__blurhash" />
<img v-if="attachment !== null"
class="attachment__preview"
:src="attachment.preview_url"
@load="previewLoaded = true">
<NcLoadingIcon v-if="attachment === null || !previewLoaded" :size="40" />
</div>
</template>
<script>
import { decode } from 'blurhash'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
export default {
name: 'MediaAttachment',
components: {
NcLoadingIcon,
},
props: {
/** @type {import('vue').PropType<import('../types/Mastodon').MediaAttachment>} */
attachment: {
type: Object,
default: null,
},
},
data() {
return {
previewLoaded: false,
}
},
watch: {
attachment() {
this.drawBlurhash()
},
},
mounted() {
this.drawBlurhash()
},
methods: {
drawBlurhash() {
if (this.attachment?.meta.small.width === undefined) {
return
}
const ctx = this.$refs.canvas.getContext('2d')
const imageData = ctx.createImageData(this.attachment.meta.small.width, this.attachment.meta.small.height)
const pixels = decode(this.attachment.blurhash, this.attachment.meta.small.width, this.attachment.meta.small.height)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
},
},
}
</script>
<style scoped lang="scss">
.attachment {
position: relative;
height: 100%;
width: 100%;
&__blurhash, &__preview {
position: absolute;
top: 0;
height: 100%;
width: 100%;
object-fit: cover;
}
.loading-icon {
position: absolute;
top: calc(50% - 20px);
left: calc(50% - 20px);
}
}
</style>

Wyświetl plik

@ -1,48 +1,39 @@
import Vue from 'vue' import Vue from 'vue'
import Emoji from './Emoji.vue' import Emoji from './Emoji.vue'
/**
* @typedef {object} MessageSource
* @property {Array} tag
* @property {string} content
*/
export default Vue.component('MessageContent', { export default Vue.component('MessageContent', {
props: { props: {
source: { item: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
render(createElement) { render(createElement) {
return formatMessage(createElement, this.source) return formatMessage(createElement, this.item)
}, },
}) })
/** /**
* Transform the message source into Vue elements * Transform the Status into Vue elements
* *
* filters out all tags except <br />, <p>, <span> and <a>. * filters out all tags except <br />, <p>, <span> and <a>.
* *
* Links that are hashtags or mentions are rewritten to link to the local profile or hashtag page * Links that are hashtags or mentions are rewritten to link to the local profile or hashtag page
* All external links have `rel="nofollow noopener noreferrer"` and `target="_blank"` set. * All external links have `rel="nofollow noopener noreferrer"` and `target="_blank"` set.
* *
* All attributes other than `href` for links are stripped from the source * All attributes other than `href` for links are stripped from the content
* *
* @param {Function} createElement * @param {Function} createElement
* @param {MessageSource} source * @param {import('../types/Mastodon').Status} item
*/ */
export function formatMessage(createElement, source) { export function formatMessage(createElement, item) {
if (!source.tag) { if (!item.tags) {
source.tag = [] item.tags = []
} }
const mentions = source.tag.filter(tag => tag.type === 'Mention')
const hashtags = source.tag.filter(tag => tag.type === 'Hashtag')
const parser = new DOMParser() const parser = new DOMParser()
const dom = parser.parseFromString(`<div id="rootwrapper">${source.content}</div>`, 'text/html') const dom = parser.parseFromString(`<div id="rootwrapper">${item.content}</div>`, 'text/html')
const element = dom.getElementById('rootwrapper') const element = dom.getElementById('rootwrapper')
const cleaned = cleanCopy(createElement, element, { mentions, hashtags }) const cleaned = cleanCopy(createElement, element, item)
return cleaned return cleaned
} }
@ -50,7 +41,7 @@ export function formatMessage(createElement, source) {
* *
* @param {Function} createElement * @param {Function} createElement
* @param {HTMLElement} node * @param {HTMLElement} node
* @param {object} context * @param {import('../types/Mastodon').Status} context
*/ */
function domToVue(createElement, node, context) { function domToVue(createElement, node, context) {
switch (node.tagName) { switch (node.tagName) {
@ -133,7 +124,7 @@ function transformText(createElement, text) {
* *
* @param {Function} createElement * @param {Function} createElement
* @param {HTMLElement} node * @param {HTMLElement} node
* @param {object} context * @param {import('../types/Mastodon').Status} context
*/ */
function cleanCopy(createElement, node, context) { function cleanCopy(createElement, node, context) {
const children = Array.from(node.childNodes).map(node => domToVue(createElement, node, context)) const children = Array.from(node.childNodes).map(node => domToVue(createElement, node, context))
@ -144,8 +135,7 @@ function cleanCopy(createElement, node, context) {
* *
* @param {Function} createElement * @param {Function} createElement
* @param {HTMLLinkElement} node * @param {HTMLLinkElement} node
* @param {object} context * @param {import('../types/Mastodon').Status} context
* @param {Array} context.mentions
*/ */
function cleanLink(createElement, node, context) { function cleanLink(createElement, node, context) {
const type = getLinkType(node.className) const type = getLinkType(node.className)

Wyświetl plik

@ -1,12 +1,14 @@
<template> <template>
<div class="post-attachments"> <div class="post-attachments">
<div v-for="(item, index) in attachments" <div class="attachments-container">
:key="index" <div v-for="(item, index) in attachementsSlice"
class="post-attachment" :key="index"
@click="showModal(index)"> class="attachment"
<img v-if="item.mimeType.startsWith('image/')" :src="imageUrl(item)"> @click="showModal(index)">
<div v-else> <MediaAttachment :attachment="item" />
{{ item }} </div>
<div v-if="attachments.length > 4" class="attachment more-attachments">
+
</div> </div>
</div> </div>
<NcModal v-if="modal" <NcModal v-if="modal"
@ -14,10 +16,10 @@
:has-next="current < (attachments.length - 1)" :has-next="current < (attachments.length - 1)"
size="full" size="full"
@close="closeModal" @close="closeModal"
@previous="showPrevious" @previous="current--"
@next="showNext"> @next="current++">
<div class="modal__content"> <div class="attachment__viewer">
<canvas ref="modalCanvas" /> <img :src="attachments[current].url" :alt="attachments[current].description">
</div> </div>
</NcModal> </NcModal>
</div> </div>
@ -26,17 +28,19 @@
<script> <script>
import serverData from '../mixins/serverData.js' import serverData from '../mixins/serverData.js'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
import { generateUrl } from '@nextcloud/router' import MediaAttachment from './MediaAttachment.vue'
export default { export default {
name: 'PostAttachment', name: 'PostAttachment',
components: { components: {
NcModal, NcModal,
MediaAttachment,
}, },
mixins: [ mixins: [
serverData, serverData,
], ],
props: { props: {
/** @type {import('vue').PropType<import('../types/Mastodon.js').MediaAttachment[]>} */
attachments: { attachments: {
type: Array, type: Array,
default: Array, default: Array,
@ -45,90 +49,72 @@ export default {
data() { data() {
return { return {
modal: false, modal: false,
current: '', current: 0,
} }
}, },
methods: { computed: {
/** /** @return {import('../types/Mastodon.js').MediaAttachment[]} */
* @function imageUrl attachementsSlice() {
* @description Returns the URL where to get a resized version of the attachement if (this.attachments.length <= 4) {
* @param {object} item - The attachment return this.attachments
* @return {string} The URL
*/
imageUrl(item) {
if (this.serverData.public) {
return generateUrl('/apps/social/document/public/resized?id=' + item.id)
} else { } else {
return generateUrl('/apps/social/document/get/resized?id=' + item.id) return this.attachments.slice(0, 3)
} }
}, },
/** },
* @function displayImage methods: {
* @description Displays the currently selected attachment's image showModal(index) {
*/ this.current = index
displayImage() {
const canvas = this.$refs.modalCanvas
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = function() {
let width = img.width
let height = img.height
if (width > window.innerWidth) {
height = height * (window.innerWidth / width)
width = window.innerWidth
}
if (height > window.innerHeight) {
width = width * (window.innerHeight / height)
height = window.innerHeight
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
}
img.src = generateUrl('/apps/social/document/get?id=' + this.attachments[this.current].id)
},
showModal(idx) {
this.current = idx
this.displayImage()
this.modal = true this.modal = true
}, },
closeModal() { closeModal() {
this.modal = false this.modal = false
}, },
showPrevious() {
this.current--
this.displayImage()
},
showNext() {
this.current++
this.displayImage()
},
}, },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.post-attachments { .post-attachments {
margin-top: 12px; .attachments-container {
width: 100%; display: flex;
display: flex; flex-wrap: wrap;
gap: 12px; gap: 2px;
overflow-x: scroll; margin-top: 12px;
width: 100%;
.post-attachment {
height: 100px;
object-fit: cover;
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
overflow: hidden; overflow: hidden;
flex-shrink: 0; height: 40vh;
> * { .attachment {
flex-grow: 1;
flex-shrink: 1;
flex-basis: calc(50% - 2px);
cursor: pointer; cursor: pointer;
} }
img { .more-attachments {
width: 100%; display: flex;
height: 100%; align-items: center;
justify-content: center;
font-size: 42px;
line-height: 0px;
} }
} }
} }
.attachment__viewer {
display: flex;
height: 100%;
width: 100%;
align-content: center;
justify-items: center;
padding: 10%;
box-sizing: border-box;
img {
height: 100%;
width: 100%;
object-fit: contain;
}
}
</style> </style>

Wyświetl plik

@ -22,7 +22,7 @@
<template> <template>
<div v-if="profileAccount && accountInfo" class="user-profile"> <div v-if="profileAccount && accountInfo" class="user-profile">
<NcAvatar v-if="accountInfo.local" <NcAvatar v-if="isLocal"
:user="localUid" :user="localUid"
:disable-tooltip="true" :disable-tooltip="true"
:size="128" /> :size="128" />
@ -32,32 +32,32 @@
:size="128" /> :size="128" />
<h2>{{ displayName }}</h2> <h2>{{ displayName }}</h2>
<!-- TODO: we have no details, timeline and follower list for non-local accounts for now --> <!-- TODO: we have no details, timeline and follower list for non-local accounts for now -->
<ul v-if="accountInfo.details && accountInfo.local" class="user-profile__info user-profile__sections"> <ul v-if="isLocal" class="user-profile__info user-profile__sections">
<li> <li>
<router-link :to="{ name: 'profile', params: { account: uid } }" class="icon-category-monitoring"> <router-link :to="{ name: 'profile', params: { account: uid } }" class="icon-category-monitoring">
{{ getCount('post') }} {{ t('social', 'posts') }} {{ accountInfo.statuses_count }} {{ t('social', 'posts') }}
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'profile.following', params: { account: uid } }" class="icon-category-social"> <router-link :to="{ name: 'profile.following', params: { account: uid } }" class="icon-category-social">
{{ getCount('following') }} {{ t('social', 'following') }} {{ accountInfo.following_count }} {{ t('social', 'following') }}
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'profile.followers', params: { account: uid } }" class="icon-category-social"> <router-link :to="{ name: 'profile.followers', params: { account: uid } }" class="icon-category-social">
{{ getCount('followers') }} {{ t('social', 'followers') }} {{ accountInfo.followers_count }} {{ t('social', 'followers') }}
</router-link> </router-link>
</li> </li>
</ul> </ul>
<p class="user-profile__info"> <p class="user-profile__info">
<a :href="accountInfo.url" target="_blank">@{{ accountInfo.account }}</a> <a :href="accountInfo.url" target="_blank">@{{ accountInfo.acct }}</a>
</p> </p>
<p v-if="accountInfo.website" class="user-profile__info"> <p v-if="website" class="user-profile__info">
{{ t('social', 'Website') }}: <a :href="accountInfo.website.value">{{ accountInfo.website.value }}</a> {{ t('social', 'Website') }}: <a :href="website.value">{{ website.value }}</a>
</p> </p>
<FollowButton class="user-profile__info" :account="accountInfo.account" :uid="uid" /> <FollowButton class="user-profile__info" :account="accountInfo.acct" :uid="uid" />
<NcButton v-if="serverData.public" <NcButton v-if="serverData.public"
class="user-profile__info primary" class="user-profile__info primary"
@click="followRemote"> @click="followRemote">
@ -72,9 +72,9 @@ import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import accountMixins from '../mixins/accountMixins.js' import accountMixins from '../mixins/accountMixins.js'
import serverData from '../mixins/serverData.js' import serverData from '../mixins/serverData.js'
import currentUser from '../mixins/currentUserMixin.js' import currentUser from '../mixins/currentUserMixin.js'
import follow from '../mixins/follow.js'
import FollowButton from './FollowButton.vue' import FollowButton from './FollowButton.vue'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { translate } from '@nextcloud/l10n'
export default { export default {
name: 'ProfileInfo', name: 'ProfileInfo',
@ -87,7 +87,6 @@ export default {
accountMixins, accountMixins,
currentUser, currentUser,
serverData, serverData,
follow,
], ],
props: { props: {
uid: { uid: {
@ -101,31 +100,30 @@ export default {
} }
}, },
computed: { computed: {
/** @return {string} */
localUid() { localUid() {
// Returns only the local part of a username // Returns only the local part of a username
return (this.uid.indexOf('@') === -1) ? this.uid : this.uid.slice(0, this.uid.indexOf('@')) return (this.uid.indexOf('@') === -1) ? this.uid : this.uid.slice(0, this.uid.indexOf('@'))
}, },
/** @return {string} */
displayName() { displayName() {
if (typeof this.accountInfo.name !== 'undefined' && this.accountInfo.name !== '') { return this.accountInfo.display_name ?? this.accountInfo.username ?? this.profileAccount
return this.accountInfo.name
}
if (typeof this.accountInfo.preferredUsername !== 'undefined' && this.accountInfo.preferredUsername !== '') {
return this.accountInfo.preferredUsername
}
return this.profileAccount
},
getCount() {
const account = this.accountInfo
return (field) => account.details.count ? account.details.count[field] : ''
}, },
/** @return {string} */
avatarUrl() { avatarUrl() {
return generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.accountInfo.id) return generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.accountInfo.id)
}, },
/** @return {import('../types/Mastodon.js').Field} */
website() {
return this.accountInfo.fields.find(field => field.name === 'Website')
},
}, },
methods: { methods: {
followRemote() { followRemote() {
window.open(generateUrl('/apps/social/api/v1/ostatus/followRemote/' + encodeURI(this.localUid)), 'followRemote', 'width=433,height=600toolbar=no,menubar=no,scrollbars=yes,resizable=yes') window.open(generateUrl('/apps/social/api/v1/ostatus/followRemote/' + encodeURI(this.localUid)), 'followRemote', 'width=433,height=600toolbar=no,menubar=no,scrollbars=yes,resizable=yes')
}, },
t: translate,
}, },
} }

Wyświetl plik

@ -57,6 +57,7 @@ import UserEntry from './UserEntry.vue'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import Trend from 'vuetrend' import Trend from 'vuetrend'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { translate } from '@nextcloud/l10n'
export default { export default {
name: 'Search', name: 'Search',
@ -80,6 +81,7 @@ export default {
} }
}, },
computed: { computed: {
/** @return {import('../types/Mastodon.js').Account[]} */
allResults() { allResults() {
if (this.results.accounts) { if (this.results.accounts) {
if (this.results.accounts.exact) { if (this.results.accounts.exact) {
@ -138,6 +140,8 @@ export default {
remoteSearch(term) { remoteSearch(term) {
return axios.get(generateUrl('apps/social/api/v1/global/account/info?account=' + term)) return axios.get(generateUrl('apps/social/api/v1/global/account/info?account=' + term))
}, },
t: translate,
}, },
} }
</script> </script>

Wyświetl plik

@ -1,14 +1,15 @@
<template> <template>
<div v-if="item.actor_info" class="post-avatar"> <div v-if="item.account" class="post-avatar">
<NcAvatar v-if="item.local" <NcAvatar v-if="isLocal"
class="messages__avatar__icon" class="messages__avatar__icon"
:show-user-status="false" :show-user-status="false"
menu-position="left" menu-position="left"
:user="userTest" :user="userTest"
:display-name="item.actor_info.account" :display-name="item.account.acct"
:url="item.account.avatar"
:disable-tooltip="true" /> :disable-tooltip="true" />
<NcAvatar v-else <NcAvatar v-else
:url="avatarUrl" :url="item.account.avatar"
:disable-tooltip="true" /> :disable-tooltip="true" />
</div> </div>
</template> </template>
@ -22,17 +23,22 @@ export default {
NcAvatar, NcAvatar,
}, },
props: { props: {
/** @type {import('vue').PropType<import('../types/Mastodon.js').Status>} */
item: { item: {
type: Object, type: Object,
default: () => {}, default: () => {},
}, },
}, },
computed: { computed: {
/**
* @return {string}
*/
userTest() { userTest() {
return this.item.actor_info.preferredUsername return this.item.account.display_name
}, },
avatarUrl() { /** @return {boolean} */
return OC.generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.item.attributedTo) isLocal() {
return !this.item.account.acct.includes('@')
}, },
}, },
} }

Wyświetl plik

@ -1,36 +1,39 @@
<template> <template>
<div :class="['timeline-entry', hasHeader ? 'with-header' : '']"> <div :class="['timeline-entry', hasHeader ? 'with-header' : '']">
<div v-if="item.type === 'SocialAppNotification'" class="notification"> <div v-if="isNotification" class="notification">
<Bell :size="22" /> <Bell :size="22" />
<span class="notification-action"> <span class="notification-action">
{{ actionSummary }} {{ actionSummary }}
</span> </span>
</div> </div>
<template v-else-if="item.type === 'Announce'"> <template v-else-if="isBoost">
<div class="container-icon-boost boost"> <div class="container-icon-boost boost">
<span class="icon-boost" /> <span class="icon-boost" />
</div> </div>
<div class="boost"> <div class="boost">
<router-link v-if="!isProfilePage && item.actor_info" :to="{ name: 'profile', params: { account: item.local ? item.actor_info.preferredUsername : item.actor_info.account }}"> <router-link v-if="!isProfilePage && item.account"
<span v-tooltip.bottom="item.actor_info.account" class="post-author"> :to="{ name: 'profile', params: { account: item.account.username } }">
{{ userDisplayName(item.actor_info) }} <span v-tooltip.bottom="item.account.acct" class="post-author">
{{ item.account.display_name }}
</span> </span>
</router-link> </router-link>
<a v-else :href="item.attributedTo"> <a v-else :href="item.account.id">
<span class="post-author-id"> <span class="post-author-id">
{{ item.attributedTo }} {{ item.account.id }}
</span> </span>
</a> </a>
{{ boosted }} {{ t('social', 'boosted') }}
</div> </div>
</template> </template>
<UserEntry v-if="item.type === 'SocialAppNotification' && item.details.actor" :key="item.details.actor.id" :item="item.details.actor" /> <UserEntry v-if="isNotification && notificationIsAboutAnAccount"
:key="item.account.id"
:item="item.account" />
<template v-else> <template v-else>
<div class="wrapper"> <div class="wrapper">
<TimelineAvatar class="entry__avatar" :item="entryContent" /> <TimelineAvatar class="entry__avatar" :item="entryContent" />
<TimelinePost class="entry__content" <TimelinePost class="entry__content"
:item="entryContent" :item="entryContent"
:parent-announce="isBoost" /> :type="type" />
</div> </div>
</template> </template>
</div> </div>
@ -41,6 +44,7 @@ import TimelinePost from './TimelinePost.vue'
import TimelineAvatar from './TimelineAvatar.vue' import TimelineAvatar from './TimelineAvatar.vue'
import UserEntry from './UserEntry.vue' import UserEntry from './UserEntry.vue'
import Bell from 'vue-material-design-icons/Bell.vue' import Bell from 'vue-material-design-icons/Bell.vue'
import { translate } from '@nextcloud/l10n'
export default { export default {
name: 'TimelineEntry', name: 'TimelineEntry',
@ -51,77 +55,91 @@ export default {
Bell, Bell,
}, },
props: { props: {
/** @type {import('vue').PropType<import('../types/Mastodon.js').Status|import('../types/Mastodon.js').Notification>} */
item: { item: {
type: Object, type: Object,
default: () => {}, default: () => {},
}, },
type: {
type: String,
required: true,
},
isProfilePage: { isProfilePage: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}, },
data() {
return {
}
},
computed: { computed: {
/**
* @return {import('../types/Mastodon.js').Status}
*/
entryContent() { entryContent() {
if (this.item.type === 'Announce') { if (this.isNotification) {
return this.item.cache[this.item.object].object return this.notification.status
} else if (this.item.type === 'SocialAppNotification') {
return this.item.details.post
} else { } else {
return this.item return this.item
} }
}, },
/** @return {boolean} */
isNotification() {
return this.item.type !== undefined
},
/**
* @return {boolean}
*/
isBoost() { isBoost() {
if (this.item.type === 'Announce') { return this.reblog !== null
return this.item
}
return {}
}, },
/** @return {import('../types/Mastodon.js').Notification} */
notification() {
return this.item
},
/** @return {import('../types/Mastodon.js').Status} */
status() {
return this.item
},
/** @return {boolean} */
notificationIsAboutAnAccount() {
return this.notification.type in ['follow', 'follow_request', 'admin.sign_up', 'admin.report']
},
/**
* @return {boolean}
*/
hasHeader() { hasHeader() {
return this.item.type === 'Announce' || this.item.type === 'SocialAppNotification' return this.isBoost || this.isNotification
},
boosted() {
return t('social', 'boosted')
}, },
/**
* @return {string}
*/
actionSummary() { actionSummary() {
switch (this.notification.type) {
let summary = this.item.summary case 'mention':
for (const key in this.item.details) { return t('social', '{account} mentioned you', { account: this.notification.account.acct })
case 'status':
const keyword = '{' + key + '}' return t('social', '{account} has posted a status', { account: this.notification.account.acct })
if (typeof this.item.details[key] !== 'string' && this.item.details[key].length > 1) { case 'reblog':
return t('social', '{account} boosted your post', { account: this.notification.account.acct })
let concatination = '' case 'follow':
for (const stringKey in this.item.details[key]) { return t('social', '{account} started following you', { account: this.notification.account.acct })
case 'follow_request':
if (this.item.details[key].length > 3 && stringKey === '3') { return t('social', '{account} requested to follow you', { account: this.notification.account.acct })
// ellipses the actors' list to 3 actors when it's big case 'favourite':
concatination = concatination.substring(0, concatination.length - 2) return t('social', '{account} like you post', { account: this.notification.account.acct })
concatination += ' and ' + (this.item.details[key].length - 3).toString() + ' other(s), ' case 'poll':
break return t('social', '{account} as ended the poll', { account: this.notification.account.acct })
} else { case 'update':
concatination += this.item.details[key][stringKey] + ', ' return t('social', '{account} edit a status', { account: this.notification.account.acct })
} case 'admin.sign_up':
} return t('social', '{account} signed up', { account: this.notification.account.acct })
case 'admin.report':
concatination = concatination.substring(0, concatination.length - 2) return t('social', '{account} filled a report', { account: this.notification.account.acct })
summary = summary.replace(keyword, concatination) default:
return ''
} else {
summary = summary.replace(keyword, this.item.details[key])
}
} }
return summary
}, },
}, },
methods: { methods: {
userDisplayName(actorInfo) { t: translate,
return actorInfo.name !== '' ? actorInfo.name : actorInfo.preferredUsername
},
}, },
} }
</script> </script>

Wyświetl plik

@ -23,7 +23,10 @@
<template> <template>
<div class="social__timeline"> <div class="social__timeline">
<transition-group name="list" tag="div"> <transition-group name="list" tag="div">
<TimelineEntry v-for="entry in timeline" :key="entry.id" :item="entry" /> <TimelineEntry v-for="entry in timeline"
:key="entry.id"
:item="entry"
:type="type" />
</transition-group> </transition-group>
<InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler"> <InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler">
<div slot="spinner"> <div slot="spinner">
@ -41,10 +44,13 @@
<script> <script>
import InfiniteLoading from 'vue-infinite-loading' import InfiniteLoading from 'vue-infinite-loading'
import { showError } from '@nextcloud/dialogs'
import TimelineEntry from './TimelineEntry.vue' import TimelineEntry from './TimelineEntry.vue'
import CurrentUserMixin from './../mixins/currentUserMixin.js' import CurrentUserMixin from './../mixins/currentUserMixin.js'
import EmptyContent from './EmptyContent.vue' import EmptyContent from './EmptyContent.vue'
import Logger from '../logger.js' import logger from '../services/logger.js'
export default { export default {
name: 'TimelineList', name: 'TimelineList',
@ -55,7 +61,10 @@ export default {
}, },
mixins: [CurrentUserMixin], mixins: [CurrentUserMixin],
props: { props: {
type: { type: String, default: () => 'home' }, type: {
type: String,
default: () => 'home',
},
}, },
data() { data() {
return { return {
@ -87,7 +96,7 @@ export default {
title: t('social', 'No global posts found'), title: t('social', 'No global posts found'),
description: t('social', 'Posts from federated instances will show up here'), description: t('social', 'Posts from federated instances will show up here'),
}, },
liked: { favourites: {
image: 'img/undraw/likes.svg', image: 'img/undraw/likes.svg',
title: t('social', 'No liked posts found'), title: t('social', 'No liked posts found'),
}, },
@ -121,9 +130,13 @@ export default {
} }
// Fallback // Fallback
Logger.log('Did not find any empty content for this route', { routeType: this.$route.params.type, routeName: this.$route.name }) logger.log('Did not find any empty content for this route', { routeType: this.$route.params.type, routeName: this.$route.name })
return this.emptyContent.default return this.emptyContent.default
}, },
/**
* @return {import('../store/timeline.js').APObject[]}
*/
timeline() { timeline() {
return this.$store.getters.getTimeline return this.$store.getters.getTimeline
}, },
@ -132,22 +145,19 @@ export default {
}, },
methods: { methods: {
infiniteHandler($state) { async infiniteHandler($state) {
this.$store.dispatch('fetchTimeline', { try {
account: this.currentUser.uid, const response = await this.$store.dispatch('fetchTimeline', {
}).then((response) => { account: this.currentUser.uid,
if (response.status === -1) { max_id: this.timeline.length > 0 ? Number.parseInt(this.timeline[this.timeline.length - 1].id) : undefined,
OC.Notification.showTemporary('Failed to load more timeline entries') })
console.error('Failed to load more timeline entries', response)
$state.complete() response.length > 0 ? $state.loaded() : $state.complete()
return } catch (error) {
} showError('Failed to load more timeline entries')
response.result.length > 0 ? $state.loaded() : $state.complete() logger.error('Failed to load more timeline entries', { error })
}).catch((error) => {
OC.Notification.showTemporary('Failed to load more timeline entries')
console.error('Failed to load more timeline entries', error)
$state.complete() $state.complete()
}) }
}, },
}, },
} }

Wyświetl plik

@ -2,35 +2,40 @@
<div class="post-content"> <div class="post-content">
<div class="post-header"> <div class="post-header">
<div class="post-author-wrapper"> <div class="post-author-wrapper">
<router-link v-if="item.actor_info" <!-- TODO -->
<router-link v-if="item.account"
:to="{ name: 'profile', :to="{ name: 'profile',
params: { account: (item.local && item.type!=='SocialAppNotification') ? item.actor_info.preferredUsername : item.actor_info.account } params: { account: (isLocal && !isNotification) ? item.account.display_name : item.account.username }
}"> }">
<span class="post-author"> <span class="post-author">
{{ userDisplayName(item.actor_info) }} {{ item.account.display_name }}
</span> </span>
<span class="post-author-id"> <span class="post-author-id">
@{{ item.actor_info.account }} @{{ item.account.username }}
</span> </span>
</router-link> </router-link>
<a v-else :href="item.attributedTo"> <a v-else :href="item.account.id">
<span class="post-author-id"> <span class="post-author-id">
{{ item.attributedTo }} {{ item.account.id }}
</span> </span>
</a> </a>
</div> </div>
<a :data-timestamp="timestamp" class="post-timestamp live-relative-timestamp" @click="getSinglePostTimeline"> <a :data-timestamp="timestamp"
class="post-timestamp live-relative-timestamp"
:title="formattedDate"
@click="getSinglePostTimeline">
{{ relativeTimestamp }} {{ relativeTimestamp }}
</a> </a>
</div> </div>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="item.content" class="post-message"> <div v-if="item.content" class="post-message">
<MessageContent :source="source" /> <MessageContent :item="item" />
</div> </div>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-else class="post-message" v-html="item.actor_info.summary" /> <div v-else class="post-message" v-html="item.account.note" />
<div v-if="hasAttachments" class="post-attachments"> <div v-if="hasAttachments" class="post-attachments">
<PostAttachment :attachments="item.attachment" /> <!-- TODO: clean media_attachments -->
<PostAttachment :attachments="item.media_attachments || []" />
</div> </div>
<div v-if="$route && $route.params.type !== 'notifications' && !serverData.public" class="post-actions"> <div v-if="$route && $route.params.type !== 'notifications' && !serverData.public" class="post-actions">
<NcButton v-tooltip="t('social', 'Reply')" <NcButton v-tooltip="t('social', 'Reply')"
@ -64,7 +69,7 @@
</template> </template>
</NcButton> </NcButton>
<NcActions> <NcActions>
<NcActionButton v-if="item.actor_info.account === cloudId" <NcActionButton v-if="item.account !== undefined && item.account.acct === currentAccount.acct"
icon="icon-delete" icon="icon-delete"
@click="remove()"> @click="remove()">
{{ t('social', 'Delete') }} {{ t('social', 'Delete') }}
@ -90,7 +95,6 @@ import Heart from 'vue-material-design-icons/Heart.vue'
import HeartOutline from 'vue-material-design-icons/HeartOutline.vue' import HeartOutline from 'vue-material-design-icons/HeartOutline.vue'
import logger from '../services/logger.js' import logger from '../services/logger.js'
import moment from '@nextcloud/moment' import moment from '@nextcloud/moment'
import { generateUrl } from '@nextcloud/router'
import MessageContent from './MessageContent.js' import MessageContent from './MessageContent.js'
export default { export default {
@ -108,47 +112,75 @@ export default {
}, },
mixins: [currentUser], mixins: [currentUser],
props: { props: {
item: { type: Object, default: () => {} }, /** @type {import('vue').PropType<import('../types/Mastodon.js').Status>} */
parentAnnounce: { type: Object, default: () => {} }, item: {
type: Object,
default: () => {},
},
type: {
type: String,
required: true,
},
}, },
computed: { computed: {
/**
* @return {string}
*/
relativeTimestamp() { relativeTimestamp() {
return moment(this.item.published).fromNow() return moment(this.item.created_at).fromNow()
}, },
/**
* @return {string}
*/
formattedDate() {
return moment(this.item.created_at).format('LLL')
},
/**
* @return {number}
*/
timestamp() { timestamp() {
return Date.parse(this.item.published) return Date.parse(this.item.created_at)
},
source() {
if (!this.item.source && this.item.content) {
// local posts don't have a source json
return {
content: this.item.content,
tag: [],
}
}
return JSON.parse(this.item.source)
},
avatarUrl() {
return generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.item.attributedTo)
}, },
/**
* @return {boolean}
*/
hasAttachments() { hasAttachments() {
return (typeof this.item.attachment !== 'undefined') // TODO: clean media_attachments
return (this.item.media_attachments || []).length > 0
}, },
/**
* @return {boolean}
*/
isBoosted() { isBoosted() {
if (typeof this.item.action === 'undefined') { return this.item.reblogged === true
return false
}
return !!this.item.action.values.boosted
}, },
/**
* @return {boolean}
*/
isLiked() { isLiked() {
if (typeof this.item.action === 'undefined') { return this.item.favourited === true
return false
}
return !!this.item.action.values.liked
}, },
/**
* @return {object}
*/
richParameters() { richParameters() {
return {} return {}
}, },
/**
* @return {boolean}
*/
isLocal() {
return !this.item.account.acct.includes('@')
},
/** @return {import('../types/Mastodon.js').Account} */
currentAccount() {
return this.$store.getters.currentAccount
},
/** @return {boolean} */
isNotification() {
return this.item.type !== undefined
},
}, },
methods: { methods: {
/** /**
@ -158,21 +190,22 @@ export default {
*/ */
getSinglePostTimeline(e) { getSinglePostTimeline(e) {
// Display internal or external post // Display internal or external post
if (!this.item.local) { if (!this.isLocal) {
if (this.item.type === 'Note') { if (this.type === 'Note') {
window.open(this.item.id) window.open(this.item.id)
} else if (this.item.type === 'Announce') { } else if (this.type === 'Announce') {
// TODO
window.open(this.item.object) window.open(this.item.object)
} else { } else {
logger.warn("Don't know what to do with posts of type " + this.item.type, { post: this.item }) logger.warn("Don't know what to do with posts of type " + this.type, { post: this.item })
} }
} else { } else {
this.$router.push({ this.$router.push({
name: 'single-post', name: 'single-post',
params: { params: {
account: this.item.actor_info.preferredUsername, account: this.item.account.display_name,
id: this.item.id, id: this.item.id,
localId: this.item.id.split('/')[this.item.id.split('/').length - 1], localId: this.item.uri.split('/').pop(),
type: 'single-post', type: 'single-post',
}, },
}) })
@ -188,7 +221,7 @@ export default {
boost() { boost() {
const params = { const params = {
post: this.item, post: this.item,
parentAnnounce: this.parentAnnounce, parentAnnounce: this.reblog,
} }
if (this.isBoosted) { if (this.isBoosted) {
this.$store.dispatch('postUnBoost', params) this.$store.dispatch('postUnBoost', params)
@ -202,7 +235,7 @@ export default {
like() { like() {
const params = { const params = {
post: this.item, post: this.item,
parentAnnounce: this.parentAnnounce, parentAnnounce: this.reblog,
} }
if (this.isLiked) { if (this.isLiked) {
this.$store.dispatch('postUnlike', params) this.$store.dispatch('postUnlike', params)

Wyświetl plik

@ -24,19 +24,19 @@
<div v-if="item" class="user-entry"> <div v-if="item" class="user-entry">
<div class="entry-content"> <div class="entry-content">
<div class="user-avatar"> <div class="user-avatar">
<NcAvatar v-if="item.local" <NcAvatar v-if="isLocal"
:size="32" :size="32"
:user="item.preferredUsername" :user="item.username"
:disable-tooltip="true" /> :disable-tooltip="true" />
<NcAvatar v-else :url="avatarUrl" /> <NcAvatar v-else :url="item.avatar" />
</div> </div>
<div class="user-details"> <div class="user-details">
<router-link v-if="!serverData.public" :to="{ name: 'profile', params: { account: item.local ? item.preferredUsername : item.account }}"> <router-link v-if="!serverData.public" :to="{ name: 'profile', params: { account: item.acct }}">
<span class="post-author"> <span class="post-author">
{{ item.name }} {{ item.display_name }}
</span> </span>
<span class="user-description"> <span class="user-description">
{{ item.account }} {{ item.acct }}
</span> </span>
</router-link> </router-link>
<a v-else <a v-else
@ -44,26 +44,24 @@
target="_blank" target="_blank"
rel="noreferrer"> rel="noreferrer">
<span class="post-author"> <span class="post-author">
{{ item.name }} {{ item.display_name }}
</span> </span>
<span class="user-description"> <span class="user-description">
{{ item.account }} {{ item.acct }}
</span> </span>
</a> </a>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="item.summary" /> <p v-html="item.note" />
</div> </div>
<FollowButton :account="item.account" :uid="cloudId" /> <FollowButton :account="item.acct" :uid="cloudId" />
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import follow from '../mixins/follow.js'
import currentUser from '../mixins/currentUserMixin.js' import currentUser from '../mixins/currentUserMixin.js'
import FollowButton from './FollowButton.vue' import FollowButton from './FollowButton.vue'
import { generateUrl } from '@nextcloud/router'
export default { export default {
name: 'UserEntry', name: 'UserEntry',
@ -72,11 +70,14 @@ export default {
NcAvatar, NcAvatar,
}, },
mixins: [ mixins: [
follow,
currentUser, currentUser,
], ],
props: { props: {
item: { type: Object, default: () => {} }, /** @type {import('vue').PropType<import('../types/Mastodon.js').Account>} */
item: {
type: Object,
default: () => {},
},
}, },
data() { data() {
return { return {
@ -84,14 +85,11 @@ export default {
} }
}, },
computed: { computed: {
id() { /**
if (this.item.actor_info) { * @return {boolean}
return this.item.actor_info.id */
} isLocal() {
return this.item.id return !this.item.acct.includes('@')
},
avatarUrl() {
return generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.id)
}, },
}, },
} }

Wyświetl plik

@ -1,28 +0,0 @@
/*
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getLoggerBuilder } from '@nextcloud/logger'
import { getCurrentUser } from '@nextcloud/auth'
export default getLoggerBuilder()
.setApp('social')
.setUid(getCurrentUser().uid)
.build()

Wyświetl plik

@ -27,9 +27,9 @@ import App from './App.vue'
import store from './store/index.js' import store from './store/index.js'
import router from './router.js' import router from './router.js'
import vuetwemoji from 'vue-twemoji' import vuetwemoji from 'vue-twemoji'
import contenteditableDirective from 'vue-contenteditable-directive'
import ClickOutside from 'vue-click-outside' import ClickOutside from 'vue-click-outside'
import VTooltip from '@nextcloud/vue/dist/Directives/Tooltip.js' import VTooltip from '@nextcloud/vue/dist/Directives/Tooltip.js'
import VueMasonry from 'vue-masonry-css'
sync(store, router) sync(store, router)
@ -49,13 +49,13 @@ Vue.prototype.OCA = OCA
Vue.directive('ClickOutside', ClickOutside) Vue.directive('ClickOutside', ClickOutside)
Vue.directive('Tooltip', VTooltip) Vue.directive('Tooltip', VTooltip)
Vue.use(contenteditableDirective)
Vue.use(vuetwemoji, { Vue.use(vuetwemoji, {
baseUrl: OC.linkTo('social', 'img/'), // can set to local folder of emojis. default: https://twemoji.maxcdn.com/ baseUrl: OC.linkTo('social', 'img/'), // can set to local folder of emojis. default: https://twemoji.maxcdn.com/
extension: '.svg', // .svg, .png extension: '.svg', // .svg, .png
className: 'emoji', // custom className for image output className: 'emoji', // custom className for image output
size: 'twemoji', // image size size: 'twemoji', // image size
}) })
Vue.use(VueMasonry)
/* eslint-disable-next-line no-new */ /* eslint-disable-next-line no-new */
new Vue({ new Vue({

Wyświetl plik

@ -36,7 +36,7 @@ export default {
return (this.uid.indexOf('@') === -1) ? this.uid + '@' + this.hostname : this.uid return (this.uid.indexOf('@') === -1) ? this.uid + '@' + this.hostname : this.uid
}, },
/** @return detailed information about an account (account must be loaded in the store first) */ /** @return {import('../types/Mastodon.js').Account} detailed information about an account (account must be loaded in the store first) */
accountInfo() { accountInfo() {
return this.$store.getters.getAccount(this.profileAccount) return this.$store.getters.getAccount(this.profileAccount)
}, },
@ -44,9 +44,20 @@ export default {
/** /**
* Somewhat duplicate with accountInfo(), but needed (for some reason) to avoid glitches * Somewhat duplicate with accountInfo(), but needed (for some reason) to avoid glitches
* where components would first show "user not found" before display an account's account info * where components would first show "user not found" before display an account's account info
*
* @return {boolean}
*/ */
accountLoaded() { accountLoaded() {
return this.$store.getters.accountLoaded(this.profileAccount) return this.$store.getters.accountLoaded(this.profileAccount) !== undefined
},
/** @return {boolean} */
isLocal() {
return !this.accountInfo.acct.includes('@')
},
/** @return {import('../types/Mastodon.js').Relationship} */
relationship() {
return this.$store.getters.getRelationshipWith(this.accountInfo.id)
}, },
}, },
} }

Wyświetl plik

@ -1,4 +1,4 @@
/* /**
* @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
* *
* @author Julius Härtl <jus@bitgrid.net> * @author Julius Härtl <jus@bitgrid.net>
@ -20,14 +20,17 @@
* *
*/ */
import { getCurrentUser } from '@nextcloud/auth'
import serverData from './serverData.js' import serverData from './serverData.js'
export default { export default {
mixins: [ mixins: [
serverData, serverData,
], ],
computed: { computed: {
currentUser() { currentUser() {
return OC.getCurrentUser() return getCurrentUser()
}, },
socialId() { socialId() {
return '@' + this.cloudId return '@' + this.cloudId

Wyświetl plik

@ -1,71 +0,0 @@
/*
* @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
class FollowException {
}
class UnfollowException {
}
export default {
data() {
return {
followLoading: false,
}
},
methods: {
follow() {
this.followLoading = true
return axios.put(generateUrl('/apps/social/api/v1/current/follow?account=' + this.item.account)).then((response) => {
this.followLoading = false
if (response.data.status === -1) {
throw new FollowException()
}
this.item.details.following = true
}).catch((error) => {
this.followLoading = false
OC.Notification.showTemporary(`Failed to follow user ${this.item.account}`)
console.error(`Failed to follow user ${this.item.account}`, error.response.data)
})
},
unfollow() {
this.followLoading = true
return axios.delete(generateUrl('/apps/social/api/v1/current/follow?account=' + this.item.account)).then((response) => {
this.followLoading = false
if (response.data.status === -1) {
throw new UnfollowException()
}
this.item.details.following = false
}).catch((error) => {
this.followLoading = false
OC.Notification.showTemporary(`Failed to unfollow user ${this.item.account}`)
console.error(`Failed to unfollow user ${this.item.account}`, error.response.data)
})
},
},
}

Wyświetl plik

@ -1,4 +1,4 @@
/* /**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
* *
* @author Julius Härtl <jus@bitgrid.net> * @author Julius Härtl <jus@bitgrid.net>

Wyświetl plik

@ -39,7 +39,7 @@
export default { export default {
computed: { computed: {
/** /**
* @return {Partial<ServerData>} Returns the serverData object * @return {ServerData} Returns the serverData object
*/ */
serverData() { serverData() {
if (!this.$store) { if (!this.$store) {
@ -47,6 +47,9 @@ export default {
} }
return this.$store.getters.getServerData return this.$store.getters.getServerData
}, },
/**
* @return {string}
*/
hostname() { hostname() {
const url = document.createElement('a') const url = document.createElement('a')
url.setAttribute('href', this.serverData.cloudAddress) url.setAttribute('href', this.serverData.cloudAddress)

Wyświetl plik

@ -1,4 +1,4 @@
/* /**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
* *
* @author Julius Härtl <jus@bitgrid.net> * @author Julius Härtl <jus@bitgrid.net>

Wyświetl plik

@ -59,14 +59,6 @@ export default new Router({
}, },
], ],
}, },
{
path: '/:index(index.php/)?apps/social/@:account/:localId',
components: {
default: TimelineSinglePost,
},
props: true,
name: 'single-post',
},
{ {
path: '/:index(index.php/)?apps/social/@:account', path: '/:index(index.php/)?apps/social/@:account',
components: { components: {
@ -99,6 +91,14 @@ export default new Router({
}, },
], ],
}, },
{
path: '/:index(index.php/)?apps/social/@:account/:localId',
components: {
default: TimelineSinglePost,
},
props: true,
name: 'single-post',
},
{ {
path: '/:index(index.php/)?apps/social/ostatus/follow', path: '/:index(index.php/)?apps/social/ostatus/follow',
components: { components: {

Wyświetl plik

@ -23,162 +23,234 @@
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { set } from 'vue' import { set } from 'vue'
import { generateUrl } from '@nextcloud/router' import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import logger from '../services/logger.js'
const state = { const state = {
currentAccount: {}, currentAccount: '',
/** @type {Object<string, import('../types/Mastodon.js').Account>} */
accounts: {}, accounts: {},
/** @type {Object<string, string[]>} */
accountsFollowers: {},
/** @type {Object<string, string[]>} */
accountsFollowings: {},
/** @type {Object<string, Partial<import('../types/Mastodon.js').Relationship>>} */
accountsRelationships: {},
/** @type {Object<string, string>} */
accountIdMap: {}, accountIdMap: {},
} }
/**
* @param {typeof state} state
* @param {object} payload
* @param {string} payload.actorId
* @param {import('../types/Mastodon').Account} payload.data
*/
const addAccount = (state, { actorId, data }) => { const addAccount = (state, { actorId, data }) => {
set(state.accounts, actorId, Object.assign({ set(state.accounts, actorId, { ...state.accounts[actorId], ...data })
followersList: [], set(state.accountsFollowers, actorId, [])
followingList: [], set(state.accountsFollowings, actorId, [])
details: { const accountId = (data.acct.indexOf('@') === -1) ? data.acct + '@' + new URL(data.url).hostname : data.acct
following: false, set(state.accountIdMap, accountId, data.url)
follower: false,
},
}, state.accounts[actorId], data))
set(state.accountIdMap, data.account, data.id)
} }
const _getActorIdForAccount = (account) => state.accountIdMap[account] const _getActorIdForAccount = (account) => state.accountIdMap[account]
/** @type {import('vuex').MutationTree<state, any>} */
const mutations = { const mutations = {
/**
* @param state
* @param {string} account
*/
setCurrentAccount(state, account) { setCurrentAccount(state, account) {
state.currentAccount = account state.currentAccount = account
}, },
/**
* @param state
* @param {object} payload
* @param {string} payload.actorId
* @param {import('../types/Mastodon').Account} payload.data
*/
addAccount(state, { actorId, data }) { addAccount(state, { actorId, data }) {
addAccount(state, { actorId, data }) addAccount(state, { actorId, data })
}, },
/**
* @param state
* @param {object} payload
* @param {string} payload.actorId
* @param {import('../types/Mastodon').Relationship} payload.data
*/
addRelationship(state, { actorId, data }) {
set(state.accountsRelationships, actorId, data)
},
/**
* @param state
* @param {object} root
* @param {string} root.account
* @param {import('../types/Mastodon.js').Account[]} root.data
*/
addFollowers(state, { account, data }) { addFollowers(state, { account, data }) {
const users = [] const users = []
for (const index in data) { for (const actor of data) {
const actor = data[index].actor_info users.push(actor.url)
if (typeof actor !== 'undefined' && account !== actor.account) { addAccount(state, {
users.push(actor.id) actorId: actor.url,
addAccount(state, { data: actor,
actorId: actor.id, })
data: actor,
})
}
} }
set(state.accounts[_getActorIdForAccount(account)], 'followersList', users) set(state.accountsFollowers, _getActorIdForAccount(account), users)
}, },
/**
* @param state
* @param {object} root
* @param {string} root.account
* @param {import('../types/Mastodon.js').Account[]} root.data
*/
addFollowing(state, { account, data }) { addFollowing(state, { account, data }) {
const users = [] const users = []
for (const index in data) { for (const actor of data) {
const actor = data[index].actor_info users.push(actor.url)
if (typeof actor !== 'undefined' && account !== actor.account) { addAccount(state, {
users.push(actor.id) actorId: actor.url,
addAccount(state, { data: actor,
actorId: actor.id, })
data: actor,
})
}
} }
set(state.accounts[_getActorIdForAccount(account)], 'followingList', users) set(state.accountsFollowings, _getActorIdForAccount(account), users)
}, },
followAccount(state, accountToFollow) { followAccount(state, accountToFollow) {
set(state.accounts[_getActorIdForAccount(accountToFollow)].details, 'following', true) state.accountsFollowings[_getActorIdForAccount(accountToFollow)].push(accountToFollow)
set(state.accountsRelationships[state.accounts[_getActorIdForAccount(accountToFollow)].id], 'following', true)
}, },
unfollowAccount(state, accountToUnfollow) { unfollowAccount(state, accountToUnfollow) {
set(state.accounts[_getActorIdForAccount(accountToUnfollow)].details, 'following', false) const followingList = state.accountsFollowings[_getActorIdForAccount(accountToUnfollow)]
followingList.splice(followingList.indexOf(accountToUnfollow), 1)
set(state.accountsRelationships[state.accounts[_getActorIdForAccount(accountToUnfollow)].id], 'following', false)
}, },
} }
/** @type {import('vuex').GetterTree<state, any>} */
const getters = { const getters = {
getAllAccounts(state) { getAllAccounts(state) {
return (account) => { return state.accounts } return () => { return state.accounts }
}, },
getAccount(state, getters) { getAccount(state, getters) {
return (account) => { return (/** @type {string} */ account) => {
return state.accounts[_getActorIdForAccount(account)] return state.accounts[_getActorIdForAccount(account)]
} }
}, },
getRelationshipWith(state, getters) {
return (/** @type {string} */ accountId) => {
return state.accountsRelationships[accountId]
}
},
currentAccount(state, getters) {
return getters.getAccount(state.currentAccount)
},
accountFollowing(state) { accountFollowing(state) {
return (account, isFollowing) => _getActorIdForAccount(isFollowing) in state.accounts[_getActorIdForAccount(account)] return (/** @type {string} */ account, /** @type {boolean} */ isFollowing) => _getActorIdForAccount(isFollowing) in state.accounts[_getActorIdForAccount(account)]
}, },
accountLoaded(state) { accountLoaded(state) {
return (account) => state.accounts[_getActorIdForAccount(account)] return (/** @type {string} */ account) => state.accounts[_getActorIdForAccount(account)]
}, },
getAccountFollowers(state) { getAccountFollowers(state) {
return (id) => state.accounts[_getActorIdForAccount(id)].followersList.map((actorId) => state.accounts[actorId]) return (/** @type {string} */ id) => state.accountsFollowers[_getActorIdForAccount(id)].map((actorId) => state.accounts[actorId])
}, },
getAccountFollowing(state) { getAccountFollowing(state) {
return (id) => state.accounts[_getActorIdForAccount(id)].followingList.map((actorId) => state.accounts[actorId]) return (/** @type {string} */ id) => state.accountsFollowings[_getActorIdForAccount(id)].map((actorId) => state.accounts[actorId])
}, },
getActorIdForAccount() { getActorIdForAccount() {
return _getActorIdForAccount return _getActorIdForAccount
}, },
isFollowingUser(state) { isFollowingUser(state) {
return (followingAccount) => { return (/** @type {string} */ followingAccount) => state.accountsRelationships[_getActorIdForAccount(followingAccount)]?.following || false
const account = state.accounts[_getActorIdForAccount(followingAccount)]
return account && account.details ? account.details.following : false
}
}, },
} }
/** @type {import('vuex').ActionTree<state, any>} */
const actions = { const actions = {
fetchAccountInfo(context, account) { async fetchAccountInfo(context, account) {
return axios.get(generateUrl(`apps/social/api/v1/global/account/info?account=${account}`)).then((response) => { try {
context.commit('addAccount', { actorId: response.data.result.account.id, data: response.data.result.account }) const response = await axios.get(generateUrl(`apps/social/api/v1/global/account/info?account=${account}`))
return response.data.result.account context.commit('addAccount', { actorId: response.data.url, data: response.data })
}).catch(() => { return response.data
OC.Notification.showTemporary(`Failed to load account details ${account}`) } catch (error) {
}) logger.error('Failed to load local account details', { error })
showError(`Failed to load local account details ${account}`)
}
}, },
fetchPublicAccountInfo(context, uid) { async fetchAccountRelationshipInfo(context, ids) {
return axios.get(generateUrl(`apps/social/api/v1/account/${uid}/info`)).then((response) => { try {
context.commit('addAccount', { actorId: response.data.result.account.id, data: response.data.result.account }) const response = await axios.get(generateUrl('apps/social/api/v1/accounts/relationships'), { params: { id: ids } })
return response.data.result.account response.data.forEach(account => context.commit('addRelationship', { actorId: account.id, data: account }))
}).catch(() => { return response.data
OC.Notification.showTemporary(`Failed to load account details ${uid}`) } catch (error) {
}) logger.error('Failed to load relationship info', { error })
showError('Failed to load relationship info')
}
},
async fetchPublicAccountInfo(context, uid) {
try {
const response = await axios.get(generateUrl(`apps/social/api/v1/account/${uid}/info`))
context.commit('addAccount', { actorId: response.data.url, data: response.data })
return response.data
} catch (error) {
logger.error('Failed to load public account details', { error })
showError(`Failed to load public account details ${uid}`)
}
}, },
fetchCurrentAccountInfo({ commit, dispatch }, account) { fetchCurrentAccountInfo({ commit, dispatch }, account) {
commit('setCurrentAccount', account) commit('setCurrentAccount', account)
dispatch('fetchAccountInfo', account) dispatch('fetchAccountInfo', account)
}, },
followAccount(context, { currentAccount, accountToFollow }) { async followAccount(context, { currentAccount, accountToFollow }) {
return axios.put(generateUrl('/apps/social/api/v1/current/follow?account=' + accountToFollow)).then((response) => { try {
const response = await axios.put(generateUrl('/apps/social/api/v1/current/follow?account=' + accountToFollow))
if (response.data.status === -1) { if (response.data.status === -1) {
return Promise.reject(response) return Promise.reject(response)
} }
context.commit('followAccount', accountToFollow) context.commit('followAccount', accountToFollow)
return Promise.resolve(response) return response
}).catch((error) => { } catch (error) {
OC.Notification.showTemporary(`Failed to follow user ${accountToFollow}`) showError(`Failed to follow user ${accountToFollow}`)
console.error(`Failed to follow user ${accountToFollow}`, error) logger.error(`Failed to follow user ${accountToFollow}`, { error })
}) }
}, },
unfollowAccount(context, { currentAccount, accountToUnfollow }) { async unfollowAccount(context, { currentAccount, accountToUnfollow }) {
return axios.delete(generateUrl('/apps/social/api/v1/current/follow?account=' + accountToUnfollow)).then((response) => { try {
const response = await axios.delete(generateUrl('/apps/social/api/v1/current/follow?account=' + accountToUnfollow))
if (response.data.status === -1) { if (response.data.status === -1) {
return Promise.reject(response) return Promise.reject(response)
} }
context.commit('unfollowAccount', accountToUnfollow) context.commit('unfollowAccount', accountToUnfollow)
return Promise.resolve(response) return response
}).catch((error) => { } catch (error) {
OC.Notification.showTemporary(`Failed to unfollow user ${accountToUnfollow}`) showError(`Failed to unfollow user ${accountToUnfollow}`)
console.error(`Failed to unfollow user ${accountToUnfollow}`, error.response.data) logger.error(`Failed to unfollow user ${accountToUnfollow}`, { error })
return Promise.reject(error.response.data) return error
}) }
}, },
fetchAccountFollowers(context, account) { async fetchAccountFollowers(context, account) {
// TODO: fetching followers/following information of remotes is currently not supported // TODO: fetching followers/following information of remotes is currently not supported
const parts = account.split('@') const parts = account.split('@')
const uid = (parts.length === 2 ? parts[0] : account) const uid = (parts.length === 2 ? parts[0] : account)
axios.get(generateUrl(`apps/social/api/v1/account/${uid}/followers`)).then((response) => { try {
context.commit('addFollowers', { account, data: response.data.result }) const response = await axios.get(generateUrl(`apps/social/api/v1/accounts/${uid}/followers`))
}) context.commit('addFollowers', { account, data: response.data })
} catch (error) {
showError('Failed to fetch followers list')
logger.error(`Failed to fetch followers list for user ${account}`, { error })
}
}, },
fetchAccountFollowing(context, account) { async fetchAccountFollowing(context, account) {
// TODO: fetching followers/following information of remotes is currently not supported // TODO: fetching followers/following information of remotes is currently not supported
const parts = account.split('@') const parts = account.split('@')
const uid = (parts.length === 2 ? parts[0] : account) const uid = (parts.length === 2 ? parts[0] : account)
axios.get(generateUrl(`apps/social/api/v1/account/${uid}/following`)).then((response) => { try {
context.commit('addFollowing', { account, data: response.data.result }) const response = await axios.get(generateUrl(`apps/social/api/v1/accounts/${uid}/following`))
}) context.commit('addFollowing', { account, data: response.data })
} catch (error) {
showError('Failed to fetch following list')
logger.error(`Failed to fetch following list for user ${account}`, { error })
}
}, },
} }

Wyświetl plik

@ -23,103 +23,157 @@
* *
*/ */
import logger from '../services/logger.js'
import axios from '@nextcloud/axios'
import Vue from 'vue' import Vue from 'vue'
import { generateUrl } from '@nextcloud/router'
/** import axios from '@nextcloud/axios'
* @property {object} timeline - The posts' collection import { generateUrl } from '@nextcloud/router'
* @property {number} since - Time (EPOCH) of the most recent post import { showError } from '@nextcloud/dialogs'
* @property {string} type - Timeline's type: 'home', 'single-post',...
* @property {object} params - Timeline's parameters import logger from '../services/logger.js'
* @property {string} account -
*/
const state = { const state = {
/**
* @type {Object<string, import('../types/Mastodon.js').Status>} timeline - The posts' collection
*/
timeline: {}, timeline: {},
since: Math.floor(Date.now() / 1000) + 1, /**
* @type {string} type - Timeline's type: 'home', 'single-post',...
*/
type: 'home', type: 'home',
/** /**
* @namespace params * @type {object} params - Timeline's parameters
* @property {string} account ??? * @property {string} params.account ???
* @property {string} id * @property {string} params.id
* @property {string} localId * @property {string} params.localId
* @property {string} type ??? * @property {string} params.type ???
*/ */
params: {}, params: {},
/**
* @type {string} account -
*/
account: '', account: '',
/* Tells whether the composer should be displayed or not. /**
* Tells whether the composer should be displayed or not.
* It's up to the view to honor this status or not. * It's up to the view to honor this status or not.
*
* @member {boolean} * @member {boolean}
*/ */
composerDisplayStatus: false, composerDisplayStatus: false,
} }
/** @type {import('vuex').MutationTree<state>} */
const mutations = { const mutations = {
/**
* @param state
* @param {import('../types/Mastodon.js').Status[]} data
*/
addToTimeline(state, data) { addToTimeline(state, data) {
for (const item in data) { // TODO: fix to handle ancestors
state.since = data[item].publishedTime if (data.descendants) {
Vue.set(state.timeline, data[item].id, data[item]) data = data.descendants
} }
data.forEach((post) => Vue.set(state.timeline, post.id, post))
}, },
/**
* @param state
* @param {import('../types/Mastodon.js').Status} post
*/
removePost(state, post) { removePost(state, post) {
Vue.delete(state.timeline, post.id) Vue.delete(state.timeline, post.id)
}, },
resetTimeline(state) { resetTimeline(state) {
state.timeline = {} state.timeline = {}
state.since = Math.floor(Date.now() / 1000) + 1
}, },
/**
* @param state
* @param {string} type
*/
setTimelineType(state, type) { setTimelineType(state, type) {
state.type = type state.type = type
}, },
setTimelineParams(state, params) { setTimelineParams(state, params) {
state.params = params state.params = params
}, },
/**
* @param state
* @param {boolean} status
*/
setComposerDisplayStatus(state, status) { setComposerDisplayStatus(state, status) {
state.composerDisplayStatus = status state.composerDisplayStatus = status
}, },
/**
* @param state
* @param {string} account
*/
setAccount(state, account) { setAccount(state, account) {
state.account = account state.account = account
}, },
/**
* @param state
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.parentAnnounce
*/
likePost(state, { post, parentAnnounce }) { likePost(state, { post, parentAnnounce }) {
if (typeof state.timeline[post.id] !== 'undefined') { if (typeof state.timeline[post.id] !== 'undefined') {
Vue.set(state.timeline[post.id].action.values, 'liked', true) Vue.set(state.timeline[post.id], 'favourited', true)
} }
if (typeof parentAnnounce.id !== 'undefined') { if (typeof parentAnnounce.id !== 'undefined') {
Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object.action.values, 'liked', true) Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object], 'favourited', true)
} }
}, },
/**
* @param state
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.parentAnnounce
*/
unlikePost(state, { post, parentAnnounce }) { unlikePost(state, { post, parentAnnounce }) {
if (typeof state.timeline[post.id] !== 'undefined') { if (typeof state.timeline[post.id] !== 'undefined') {
Vue.set(state.timeline[post.id].action.values, 'liked', false) Vue.set(state.timeline[post.id], 'favourited', false)
} }
if (typeof parentAnnounce.id !== 'undefined') { if (typeof parentAnnounce.id !== 'undefined') {
Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object.action.values, 'liked', false) Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object, 'favourited', false)
} }
}, },
/**
* @param state
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.parentAnnounce
*/
boostPost(state, { post, parentAnnounce }) { boostPost(state, { post, parentAnnounce }) {
if (typeof state.timeline[post.id] !== 'undefined') { if (typeof state.timeline[post.id] !== 'undefined') {
Vue.set(state.timeline[post.id].action.values, 'boosted', true) Vue.set(state.timeline[post.id], 'reblogged', true)
} }
if (typeof parentAnnounce.id !== 'undefined') { if (typeof parentAnnounce.id !== 'undefined') {
Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object.action.values, 'boosted', true) Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object, 'reblogged', true)
} }
}, },
/**
* @param state
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.parentAnnounce
*/
unboostPost(state, { post, parentAnnounce }) { unboostPost(state, { post, parentAnnounce }) {
if (typeof state.timeline[post.id] !== 'undefined') { if (typeof state.timeline[post.id] !== 'undefined') {
Vue.set(state.timeline[post.id].action.values, 'boosted', false) Vue.set(state.timeline[post.id], 'reblogged', false)
} }
if (typeof parentAnnounce.id !== 'undefined') { if (typeof parentAnnounce.id !== 'undefined') {
Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object.action.values, 'boosted', false) Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object, 'reblogged', false)
} }
}, },
} }
/** @type {import('vuex').GetterTree<state, any>} */
const getters = { const getters = {
getComposerDisplayStatus(state) { getComposerDisplayStatus(state) {
return state.composerDisplayStatus return state.composerDisplayStatus
}, },
getTimeline(state) { getTimeline(state) {
return Object.values(state.timeline).sort(function(a, b) { return Object.values(state.timeline).sort(function(a, b) {
return b.publishedTime - a.publishedTime return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
}) })
}, },
getPostFromTimeline(state) { getPostFromTimeline(state) {
@ -132,6 +186,8 @@ const getters = {
} }
}, },
} }
/** @type {import('vuex').ActionTree<state, any>} */
const actions = { const actions = {
changeTimelineType(context, { type, params }) { changeTimelineType(context, { type, params }) {
context.commit('resetTimeline') context.commit('resetTimeline')
@ -144,108 +200,183 @@ const actions = {
context.commit('setTimelineType', 'account') context.commit('setTimelineType', 'account')
context.commit('setAccount', account) context.commit('setAccount', account)
}, },
async post(context, post) { /**
* @param context
* @param {File} file
*/
async createMedia(context, file) {
try { try {
const { data } = await axios.post(generateUrl('apps/social/api/v1/post'), post, { const formData = new FormData()
headers: { formData.append('file', file)
'Content-Type': 'multipart/form-data', const { data } = await axios.post(
}, generateUrl('apps/social/api/v1/media'),
}) formData,
logger.info('Post created with token ' + data.result.token) {
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
logger.info('Media created with id ' + data.id)
return data
} catch (error) { } catch (error) {
OC.Notification.showTemporary('Failed to create a post') showError('Failed to create a media')
logger.error('Failed to create a post', { error: error.response }) logger.error('Failed to create a media', { error })
} }
}, },
/**
* @param context
* @param {import('../types/Mastodon.js').Status} post
*/
async post(context, post) {
try {
const { data } = await axios.post(generateUrl('apps/social/api/v1/statuses'), post)
logger.info('Post created with token ' + data.id)
} catch (error) {
showError('Failed to create a post')
logger.error('Failed to create a post', { error })
}
},
/**
* @param context
* @param {import('../types/Mastodon.js').Status} post
*/
postDelete(context, post) { postDelete(context, post) {
return axios.delete(generateUrl(`apps/social/api/v1/post?id=${post.id}`)).then((response) => { return axios.delete(generateUrl(`apps/social/api/v1/post?id=${post.uri}`)).then((response) => {
context.commit('removePost', post) context.commit('removePost', post)
logger.info('Post deleted with token ' + response.data.result.token) logger.info('Post deleted with token ' + response.data.result.token)
}).catch((error) => { }).catch((error) => {
OC.Notification.showTemporary('Failed to delete the post') showError('Failed to delete the post')
logger.error('Failed to delete the post', { error }) logger.error('Failed to delete the post', { error })
}) })
}, },
/**
* @param context
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.parentAnnounce
*/
postLike(context, { post, parentAnnounce }) { postLike(context, { post, parentAnnounce }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios.post(generateUrl(`apps/social/api/v1/post/like?postId=${post.id}`)).then((response) => { axios.post(generateUrl(`apps/social/api/v1/post/like?postId=${post.uri}`)).then((response) => {
context.commit('likePost', { post, parentAnnounce }) context.commit('likePost', { post, parentAnnounce })
resolve(response) resolve(response)
}).catch((error) => { }).catch((error) => {
OC.Notification.showTemporary('Failed to like post') showError('Failed to like post')
logger.error('Failed to like post', { error: error.response }) logger.error('Failed to like post', { error })
reject(error) reject(error)
}) })
}) })
}, },
/**
* @param context
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.parentAnnounce
*/
postUnlike(context, { post, parentAnnounce }) { postUnlike(context, { post, parentAnnounce }) {
return axios.delete(generateUrl(`apps/social/api/v1/post/like?postId=${post.id}`)).then((response) => { return axios.delete(generateUrl(`apps/social/api/v1/post/like?postId=${post.uri}`)).then((response) => {
context.commit('unlikePost', { post, parentAnnounce }) context.commit('unlikePost', { post, parentAnnounce })
// Remove post from list if we are in the 'liked' timeline // Remove post from list if we are in the 'liked' timeline
if (state.type === 'liked') { if (state.type === 'liked') {
context.commit('removePost', post) context.commit('removePost', post)
} }
}).catch((error) => { }).catch((error) => {
OC.Notification.showTemporary('Failed to unlike post') showError('Failed to unlike post')
logger.error('Failed to unlike post', { error }) logger.error('Failed to unlike post', { error })
}) })
}, },
/**
* @param context
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.parentAnnounce
*/
postBoost(context, { post, parentAnnounce }) { postBoost(context, { post, parentAnnounce }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios.post(generateUrl(`apps/social/api/v1/post/boost?postId=${post.id}`)).then((response) => { axios.post(generateUrl(`apps/social/api/v1/post/boost?postId=${post.uri}`)).then((response) => {
context.commit('boostPost', { post, parentAnnounce }) context.commit('boostPost', { post, parentAnnounce })
logger.info('Post boosted with token ' + response.data.result.token) logger.info('Post boosted with token ' + response.data.result.token)
resolve(response) resolve(response)
}).catch((error) => { }).catch((error) => {
OC.Notification.showTemporary('Failed to create a boost post') showError('Failed to create a boost post')
logger.error('Failed to create a boost post', { error: error.response }) logger.error('Failed to create a boost post', { error })
reject(error) reject(error)
}) })
}) })
}, },
/**
* @param context
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.parentAnnounce
*/
postUnBoost(context, { post, parentAnnounce }) { postUnBoost(context, { post, parentAnnounce }) {
return axios.delete(generateUrl(`apps/social/api/v1/post/boost?postId=${post.id}`)).then((response) => { return axios.delete(generateUrl(`apps/social/api/v1/post/boost?postId=${post.uri}`)).then((response) => {
context.commit('unboostPost', { post, parentAnnounce }) context.commit('unboostPost', { post, parentAnnounce })
logger.info('Boost deleted with token ' + response.data.result.token) logger.info('Boost deleted with token ' + response.data.result.token)
}).catch((error) => { }).catch((error) => {
OC.Notification.showTemporary('Failed to delete the boost') showError('Failed to delete the boost')
logger.error('Failed to delete the boost', { error }) logger.error('Failed to delete the boost', { error })
}) })
}, },
refreshTimeline(context) { refreshTimeline(context) {
return this.dispatch('fetchTimeline', { sinceTimestamp: Math.floor(Date.now() / 1000) + 1 }) return this.dispatch('fetchTimeline')
}, },
fetchTimeline(context, { sinceTimestamp }) { /**
*
if (typeof sinceTimestamp === 'undefined') { * @param {object} context
sinceTimestamp = state.since - 1 * @param {object} params - see https://docs.joinmastodon.org/methods/timelines
* @param {number} [params.since_id] - Fetch results newer than ID
* @param {number} [params.max_id] - Fetch results older than ID
* @param {number} [params.min_id] - Fetch results immediately newer than ID
* @param {number} [params.limit] - Maximum number of results to return. Defaults to 20 statuses. Max 40 statuses
* @param {boolean} [params.local] - Show only local statuses? Defaults to false.
* @return {Promise<object[]>}
*/
async fetchTimeline(context, params = {}) {
if (params.limit === undefined) {
params.limit = 15
} }
// Compute URl to get the data // Compute URL to get the data
let url = '' let url = ''
if (state.type === 'account') { switch (state.type) {
url = generateUrl(`apps/social/api/v1/account/${state.account}/stream?limit=25&since=` + sinceTimestamp) case 'account':
} else if (state.type === 'tags') { url = generateUrl(`apps/social/api/v1/accounts/${state.account}/statuses`)
url = generateUrl(`apps/social/api/v1/stream/tag/${state.params.tag}?limit=25&since=` + sinceTimestamp) break
} else if (state.type === 'single-post') { case 'tags':
url = generateUrl(`apps/social/local/v1/post/replies?id=${state.params.id}&limit=5&since=` + sinceTimestamp) url = generateUrl(`apps/social/api/v1/timelines/tag/${state.params.tag}`)
} else { break
url = generateUrl(`apps/social/api/v1/stream/${state.type}?limit=25&since=` + sinceTimestamp) case 'single-post':
url = generateUrl(`apps/social/api/v1/statuses/${state.params.localId}/context`)
break
case 'timeline':
url = generateUrl('apps/social/api/v1/timelines/public')
params.local = true
break
case 'federated':
url = generateUrl('apps/social/api/v1/timelines/public')
break
case 'notifications':
url = generateUrl('apps/social/api/v1/notifications')
break
default:
url = generateUrl(`apps/social/api/v1/timelines/${state.type}`)
} }
// Get the data and add them to the timeline // Get the data and add them to the timeline
return axios.get(url).then((response) => { const response = await axios.get(url, { params })
if (response.status === -1) { // Add results to timeline
throw response.message context.commit('addToTimeline', response.data)
}
// Add results to timeline return response.data
context.commit('addToTimeline', response.data.result)
return response.data
})
}, },
/**
* @param context
* @param {import('../types/Mastodon.js').Status[]} data
*/
addToTimeline(context, data) { addToTimeline(context, data) {
context.commit('addToTimeline', data) context.commit('addToTimeline', data)
}, },

Wyświetl plik

@ -0,0 +1,75 @@
/**
* @copyright Copyright (c) 2023 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @typedef APObject - https://www.w3.org/TR/activitystreams-vocabulary/#dfn-object
* @property {string} id -
* @property {string} type - Ex: 'Object'
* @property {APObject|APLink[]} attachment -
* @property {APObject|APLink[]} attributedTo - Ex: ["canonical", "preview"]
* @property {APObject|APLink[]} audience - Ex: ["canonical", "preview"]
* @property {string} content - The content or textual representation of the Object encoded as a JSON string.
* @property {Object<string, string>} contentMap - Language-tagged values for translated content.
* @property {APObject|APLink} context - Identifies the context within which the object exists or an activity was performed.
* @property {string} name - A simple, human-readable, plain-text name for the object.
* @property {Object<string, string>} nameMap - Language-tagged values for translated name.
* @property {string} endTime - Ex: "2015-01-01T06:00:00-08:00"
* @property {APObject|APLink} generator - Identifies the entity (e.g. an application) that generated the object.
* @property {APObject|APLink} icon -
* @property {APObject} image -
* @property {APObject|APLink} inReplyTo -
* @property {APObject|APLink} location -
* @property {APObject|APLink} preview -
* @property {string} published - Ex: "2015-01-01T06:00:00-08:00"
* @property {APCollection} replies -
* @property {string} startTime - Ex: "2015-01-01T06:00:00-08:00"
* @property {string} summary -
* @property {(APObject|APLink)[]} tag -
* @property {string} updated - Ex: "2015-01-01T06:00:00-08:00"
* @property {string} url -
* @property {APObject|APLink} to -
* @property {APObject|APLink} bto -
* @property {APObject|APLink} cc -
* @property {APObject|APLink} bcc -
* @property {string} mediaType - MIME Media Type. Ex: "text/html"
* @property {string} duration - Ex: "PT2H"
*/
/**
* @typedef APLink - https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link
* @property {'Link'} type - 'Link'
* @property {string} href - The target resource pointed to by a Link. Ex: "http://example.org/abc"
* @property {string[]} ref - Ex: ["canonical", "preview"]
* @property {string} mediaType - MIME Media Type. Ex: "text/html"
* @property {string} name - Ex: "An example name"
* @property {string} hrefLang - A [BCP47] Language-Tag. Ex: "en"
* @property {number} height - Ex: 100
* @property {number} width - Ex: 100
* @property {APObject|APLink} preview - Identifies an entity that provides a preview of this object.
*/
/**
* @typedef {APObject} APCollection
* @property {(APObject|APLink)[]} items -
*/
export default {}

Wyświetl plik

@ -0,0 +1,188 @@
/**
* @copyright Copyright (c) 2023 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @typedef Field
* @property {string} name - Ex: "Patreon"
* @property {string} value - Ex: "<a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span}"
* @property {string} [verified_at] - Ex: "2019-12-08T03:48:33.901Z"
*/
/**
* @typedef Card
* @property {string} url - Ex: "https://www.theguardian.com/money/2019/dec/07/i-lost-my-193000-inheritance-with-one-wrong-digit-on-my-sort-code"
* @property {string} title - Ex: "I lost my £193,000 inheritance – with one wrong digit on my sort code"
* @property {string} description - Ex: "When Peter Teichs money went to another Barclays customer, the bank offered £25 as a token gesture"
* @property {string} type - Ex: "link"
* @property {string} author_name -
* @property {string} author_url -
* @property {string} provider_name -
* @property {string} provider_url -
* @property {string} html -
* @property {number} width - Ex: 0
* @property {number} height - Ex: 0
* @property {number} [image] -
* @property {string} embed_url -
*/
/**
* @typedef Poll - https://docs.joinmastodon.org/entities/Poll/
* @property {string} id - Ex: "34830"
* @property {string} expires_at - Ex: "2019-12-05T04:05:08.302Z"
* @property {boolean} expired - Ex: true
* @property {boolean} multiple - Ex: false
* @property {number} votes_count - Ex: 10
* @property {number} [voters_count] - null
* @property {boolean} voted - Ex: true
* @property {number[]} own_votes - Ex: [1]
* @property {PollOption[]} options - Ex: []
* @property {CustomEmoji[]} emojis - []
*/
/**
* @typedef PollOption
* @property {string} title - Ex: "accept"
* @property {number} votes_count - 6
*/
/**
* @typedef StatusMention - https://docs.joinmastodon.org/entities/Status/#Mention
* @property {string} id -
* @property {string} username -
* @property {string} url -
* @property {string} acct -
*/
/**
* @typedef StatusTag - https://docs.joinmastodon.org/entities/Status/#Tag
* @property {string} name -
* @property {string} url -
*/
/**
* @typedef MediaAttachment - https://docs.joinmastodon.org/entities/MediaAttachment
@property {string} id - Ex: "22345792"
@property {string} type - Ex: "image"
@property {string} url - Ex: "22345792"
@property {string} preview_url - Ex: "https://files.mastodon.social/media_attachments/files/022/345/792/small/57859aede991da25.jpeg"
@property {string} [remote_url] -
@property {object} meta -
@property {string} description - Ex: "test media description"
@property {string} blurhash - Ex: "UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}"
*/
/**
* @typedef CustomEmoji
* @property {string} shortcode - Ex: "blobaww"
* @property {string} url - Ex: "https://files.mastodon.social/custom_emojis/images/000/011/739/original/blobaww.png"
* @property {string} static_url - Ex: "static_url": "https://files.mastodon.social/custom_emojis/images/000/011/739/static/blobaww.png"
* @property {boolean} visible_in_picker - Ex: "true"
* @property {string} category - Ex: "Blobs"
*/
/**
* @typedef Account - https://docs.joinmastodon.org/entities/Account
@property {string} id - Ex: "22345792"
* @property {string} username - Ex: "Gargron"
* @property {string} acct - Ex: "Gargron@example.com or Gargron for local users"
* @property {string} display_name - Ex: "Eugen"
* @property {boolean} locked - Ex: false
* @property {boolean} bot - Ex: false
* @property {number} discoverable - Ex: true
* @property {boolean} group - Ex: false
* @property {string} created_at - Ex: "2016-03-16T14:34:26.392Z"
* @property {string} note - Ex: "<p>Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.</p>"
* @property {string} url - Ex: "https://mastodon.social/@Gargron"
* @property {string} avatar - Ex: "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg"
* @property {string} avatar_static - Ex: "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg"
* @property {string} header - Ex: "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png"
* @property {string} header_static - Ex: "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png"
* @property {number} followers_count - Ex: 322930
* @property {number} following_count - Ex: 459
* @property {number} statuses_count - Ex: 61323
* @property {string} last_status_at - Ex: "2019-12-10T08:14:44.811Z"
* @property {CustomEmoji[]} emojis - Ex: []
* @property {Field[]} fields - Ex: []
*/
/**
* @typedef Status - https://docs.joinmastodon.org/entities/Status
* @property {string} id - Ex: "103270115826048975"
* @property {string} created_at - Ex: "2019-12-08T03:48:33.901Z"
* @property {string} [in_reply_to_id] - Ex: Ex: "103270115826048975"
* @property {number} [in_reply_to_account_id] - Ex: "1"
* @property {boolean} sensitive - Ex: false
* @property {string} spoiler_text -
* @property {string} visibility - Ex: "public"
* @property {string} language - Ex: "en"
* @property {string} uri - Ex: "https://mastodon.social/users/Gargron/statuses/103270115826048975"
* @property {string} url - Ex: "https://mastodon.social/@Gargron/103270115826048975"
* @property {number} replies_count - Ex: 5
* @property {number} reblogs_count - Ex: 6
* @property {number} favourites_count - Ex: 11
* @property {boolean} [favourited] - Ex: false
* @property {boolean} [reblogged] - Ex: false
* @property {boolean} [muted] - Ex: false
* @property {boolean} [bookmarked] - Ex: false
* @property {string} content - Ex: "<p>&quot;I lost my inheritance with one wrong digit on my sort code&quot;</p><p><a href=\"https://www.theguardian.com/money/2019/dec/07/i-lost-my-193000-inheritance-with-one-wrong-digit-on-my-sort-code\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"ellipsis\">theguardian.com/money/2019/dec</span><span class=\"invisible\">/07/i-lost-my-193000-inheritance-with-one-wrong-digit-on-my-sort-code</span}</p>"
* @property {Status} [reblog] - Ex: null
* @property {object} [application] -
* @property {string} application.name - Ex: "Web"
* @property {string} [application.website] - Ex: null
* @property {Account} account -
* @property {MediaAttachment[]} media_attachments - Ex: []
* @property {StatusMention[]} mentions - Ex: []
* @property {StatusTag[]} tags - Ex: []
* @property {CustomEmoji[]} emojis - Ex: []
* @property {Card} card -
* @property {Poll} [poll] - Ex: null
*/
/**
* @typedef Notification - https://docs.joinmastodon.org/entities/Notification
* @property {string} id - Ex: "https://example.com/users/@tommy""
* @property {"mention"|"status"|"reblog"|"follow"|"follow_request"|"favourite"|"poll"|"update"|"admin.sign_up"|"admin.report"} type - Ex: "2016-03-16T14:34:26.392Z"
* @property {string} created_at - Ex: "2016-03-16T14:34:26.392Z"
* @property {Account} account -
* @property {Status} [status] -
* @property {any} [report] -
*/
/**
* @typedef Relationship - https://docs.joinmastodon.org/entities/Relationship
* @property {string} id - The account ID. Ex: "https://example.com/users/@tommy""
* @property {boolean} following - Are you following this user?
* @property {boolean} showing_reblogs - Are you receiving this users boosts in your home timeline?
* @property {boolean} notifying - Have you enabled notifications for this user?
* @property {string[]} languages - Which languages are you following from this user?
* @property {boolean} followed_by - Are you followed by this user?
* @property {boolean} blocking - Are you blocking this user?
* @property {boolean} blocked_by - Is this user blocking you?
* @property {boolean} muting - Are you muting this user?
* @property {boolean} muting_notifications - Are you muting notifications from this user?
* @property {boolean} requested - Do you have a pending follow request for this user?
* @property {boolean} domain_blocking - Are you blocking this users domain?
* @property {boolean} endorsed - Are you featuring this user on your profile?
* @property {string} note - This users profile bio
*/
export default {}

Wyświetl plik

@ -75,6 +75,7 @@ export default {
}, },
computed: { computed: {
/** @return {object[]} */
items() { items() {
return this.notifications.map((n) => { return this.notifications.map((n) => {
return { return {
@ -88,11 +89,13 @@ export default {
} }
}) })
}, },
/** @return {number} */
lastTimestamp() { lastTimestamp() {
return this.notifications.length return this.notifications.length
? this.notifications[0].publishedTime ? this.notifications[0].publishedTime
: 0 : 0
}, },
/** @return {string} */
emptyContentMessage() { emptyContentMessage() {
if (this.state === 'error') { if (this.state === 'error') {
return t('social', 'Error getting Social notifications') return t('social', 'Error getting Social notifications')
@ -101,6 +104,7 @@ export default {
} }
return '' return ''
}, },
/** @return {string} */
emptyContentIcon() { emptyContentIcon() {
if (this.state === 'error') { if (this.state === 'error') {
return 'icon-close' return 'icon-close'

Wyświetl plik

@ -24,7 +24,7 @@
<div :class="{'icon-loading': !accountLoaded}" class="social__wrapper"> <div :class="{'icon-loading': !accountLoaded}" class="social__wrapper">
<ProfileInfo v-if="accountLoaded && accountInfo" :uid="uid" /> <ProfileInfo v-if="accountLoaded && accountInfo" :uid="uid" />
<!-- TODO: we have no details, timeline and follower list for non-local accounts for now --> <!-- TODO: we have no details, timeline and follower list for non-local accounts for now -->
<router-view v-if="accountLoaded && accountInfo && accountInfo.local" name="details" /> <router-view v-if="accountLoaded && accountInfo && isLocal" name="details" />
<NcEmptyContent v-if="accountLoaded && !accountInfo" <NcEmptyContent v-if="accountLoaded && !accountInfo"
:title="t('social', 'User not found')" :title="t('social', 'User not found')"
:description="t('social', 'Sorry, we could not find the account of {userId}', { userId: uid })"> :description="t('social', 'Sorry, we could not find the account of {userId}', { userId: uid })">
@ -57,19 +57,22 @@ export default {
data() { data() {
return { return {
state: [], state: [],
/** @type {string|null} */
uid: null, uid: null,
} }
}, },
computed: { computed: {
/** @return {import('../types/Mastodon').Status[]} */
timeline() { timeline() {
return this.$store.getters.getTimeline return this.$store.getters.getTimeline
}, },
/** @return {string} */
emptyContentImage() { emptyContentImage() {
return generateFilePath('social', 'img', 'undraw/profile.svg') return generateFilePath('social', 'img', 'undraw/profile.svg')
}, },
}, },
// Start fetching account information before mounting the component // Start fetching account information before mounting the component
beforeMount() { async beforeMount() {
this.uid = this.$route.params.account || this.serverData.account this.uid = this.$route.params.account || this.serverData.account
// Are we authenticated? // Are we authenticated?
@ -82,9 +85,10 @@ export default {
// We need to update this.uid because we may have asked info for an account whose domain part was a host-meta, // We need to update this.uid because we may have asked info for an account whose domain part was a host-meta,
// and the account returned by the backend always uses a non host-meta'ed domain for its ID // and the account returned by the backend always uses a non host-meta'ed domain for its ID
this.$store.dispatch(fetchMethod, this.profileAccount).then((response) => { /** @type {[import('../types/Mastodon').Account]} */
this.uid = response.account const response = await this.$store.dispatch(fetchMethod, this.profileAccount)
}) this.uid = response.acct
await this.$store.dispatch('fetchAccountRelationshipInfo', [this.accountInfo.id])
}, },
} }
</script> </script>

Wyświetl plik

@ -39,9 +39,11 @@ export default {
serverData, serverData,
], ],
computed: { computed: {
/** @return {string} */
profileAccount() { profileAccount() {
return (this.$route.params.account.indexOf('@') === -1) ? this.$route.params.account + '@' + this.hostname : this.$route.params.account return (this.$route.params.account.indexOf('@') === -1) ? this.$route.params.account + '@' + this.hostname : this.$route.params.account
}, },
/** @return {import('../types/Mastodon.js').Account[]} */
users() { users() {
if (this.$route.name === 'profile.followers') { if (this.$route.name === 'profile.followers') {
return this.$store.getters.getAccountFollowers(this.profileAccount) return this.$store.getters.getAccountFollowers(this.profileAccount)

Wyświetl plik

@ -34,7 +34,6 @@
<script> <script>
import Composer from './../components/Composer/Composer.vue' import Composer from './../components/Composer/Composer.vue'
import CurrentUserMixin from './../mixins/currentUserMixin.js' import CurrentUserMixin from './../mixins/currentUserMixin.js'
import follow from './../mixins/follow.js'
import TimelineList from './../components/TimelineList.vue' import TimelineList from './../components/TimelineList.vue'
export default { export default {
@ -45,7 +44,6 @@ export default {
}, },
mixins: [ mixins: [
CurrentUserMixin, CurrentUserMixin,
follow,
], ],
data() { data() {
return { return {

Wyświetl plik

@ -2,7 +2,7 @@
<div class="social__wrapper"> <div class="social__wrapper">
<ProfileInfo v-if="accountLoaded && accountInfo" :uid="uid" /> <ProfileInfo v-if="accountLoaded && accountInfo" :uid="uid" />
<Composer v-show="composerDisplayStatus" /> <Composer v-show="composerDisplayStatus" />
<TimelineEntry class="main-post" :item="mainPost" /> <TimelineEntry class="main-post" :item="mainPost" type="single-post" />
<TimelineList v-if="timeline" :type="$route.params.type" /> <TimelineList v-if="timeline" :type="$route.params.type" />
</div> </div>
</template> </template>
@ -72,14 +72,14 @@ export default {
this.$store.dispatch(this.serverData.public ? 'fetchPublicAccountInfo' : 'fetchAccountInfo', this.account).then((response) => { this.$store.dispatch(this.serverData.public ? 'fetchPublicAccountInfo' : 'fetchAccountInfo', this.account).then((response) => {
// We need to update this.uid because we may have asked info for an account whose domain part was a host-meta, // We need to update this.uid because we may have asked info for an account whose domain part was a host-meta,
// and the account returned by the backend always uses a non host-meta'ed domain for its ID // and the account returned by the backend always uses a non host-meta'ed domain for its ID
this.uid = response.account this.uid = response.username
}) })
// Fetch single post timeline // Fetch single post timeline
const params = { const params = {
account: this.account, account: this.account,
id: window.location.href, id: window.location.href,
localId: window.location.href.split('/')[window.location.href.split('/').length - 1], localId: this.mainPost.id,
type: 'single-post', type: 'single-post',
} }
this.$store.dispatch('changeTimelineType', { this.$store.dispatch('changeTimelineType', {

Wyświetl plik

@ -1,8 +1,15 @@
// SPDX-FileCopyrigthText: 2022 Carl Schwan <carl@carlschwan.eu> // SPDX-FileCopyrigthText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
const path = require('path'); const path = require('path')
const webpackConfig = require('@nextcloud/webpack-vue-config') const webpackConfig = require('@nextcloud/webpack-vue-config')
const CopyPlugin = require('copy-webpack-plugin')
webpackConfig.plugins.push(new CopyPlugin({
patterns: [
{ from: 'node_modules/twemoji/2/svg/', to: '../img/twemoji' },
],
}))
webpackConfig.entry = { webpackConfig.entry = {
social: path.join(__dirname, 'src', 'main.js'), social: path.join(__dirname, 'src', 'main.js'),