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 = {
plugins: ['@babel/plugin-syntax-dynamic-import'],
presets: [
[
'@babel/preset-env',
{
targets: {
browsers: ['last 2 versions', 'ie >= 11']
}
}
]
]
}
const babelConfig = require('@nextcloud/babel-config')
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 $max_id = 0,
int $min_id = 0,
int $since = 0
int $since_id = 0
): DataResponse {
try {
$this->initViewer(true);
@ -568,7 +568,7 @@ class ApiController extends Controller {
->setLimit($limit)
->setMaxId($max_id)
->setMinId($min_id)
->setSince($since);
->setSince($since_id);
$posts = $this->streamService->getTimeline($options);

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

306
package-lock.json wygenerowano
Wyświetl plik

@ -19,6 +19,8 @@
"@nextcloud/router": "^2.0.1",
"@nextcloud/vue": "^7.4.0",
"@nextcloud/vue-richtext": "^2.0.4",
"blurhash": "^2.0.5",
"debounce": "^1.2.1",
"he": "^1.2.0",
"linkify-plugin-mention": "^4.1.0",
"linkify-string": "^4.1.0",
@ -30,8 +32,8 @@
"v-tooltip": "^4.0.0-beta.0",
"vue": "^2.7.10",
"vue-click-outside": "^1.0.7",
"vue-contenteditable-directive": "^1.2.0",
"vue-infinite-loading": "^2.4.4",
"vue-masonry-css": "^1.0.3",
"vue-material-design-icons": "^5.0.0",
"vue-router": "^3.6.5",
"vue-tribute": "^1.0.7",
@ -47,6 +49,7 @@
"@nextcloud/eslint-config": "^8.2.0",
"@nextcloud/stylelint-config": "^2.3.0",
"@nextcloud/webpack-vue-config": "^5.4.0",
"copy-webpack-plugin": "^11.0.0",
"cypress": "^11.2.0",
"jest": "^29.3.1",
"jest-serializer-vue": "^3.1.0",
@ -3513,7 +3516,6 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"peer": true,
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@ -3527,7 +3529,6 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"peer": true,
"engines": {
"node": ">= 8"
}
@ -3537,7 +3538,6 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"peer": true,
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@ -3796,8 +3796,7 @@
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/@types/json5": {
"version": "0.0.29",
@ -4798,7 +4797,6 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@ -4816,7 +4814,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@ -4832,8 +4829,7 @@
"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,
"peer": true
"dev": true
},
"node_modules/ajv-keywords": {
"version": "3.5.2",
@ -5585,6 +5581,11 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"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": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@ -6779,6 +6780,126 @@
"dev": 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": {
"version": "3.25.5",
"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",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"peer": true,
"dependencies": {
"path-type": "^4.0.0"
},
@ -9189,15 +9309,13 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/fast-glob": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
"integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
"dev": true,
"peer": true,
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@ -9258,7 +9376,6 @@
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
"integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
"dev": true,
"peer": true,
"dependencies": {
"reusify": "^1.0.4"
}
@ -10219,7 +10336,6 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">= 4"
}
@ -13565,7 +13681,6 @@
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"peer": true,
"engines": {
"node": ">= 8"
}
@ -14425,7 +14540,6 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -14899,8 +15013,7 @@
"type": "consulting",
"url": "https://feross.org/support"
}
],
"peer": true
]
},
"node_modules/quick-lru": {
"version": "4.0.1",
@ -15298,7 +15411,6 @@
"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==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -15405,7 +15517,6 @@
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true,
"peer": true,
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
@ -15461,7 +15572,6 @@
"url": "https://feross.org/support"
}
],
"peer": true,
"dependencies": {
"queue-microtask": "^1.2.2"
}
@ -15633,7 +15743,6 @@
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
"integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
"dev": true,
"peer": true,
"dependencies": {
"randombytes": "^2.1.0"
}
@ -17410,7 +17519,6 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
"integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
"dev": true,
"peer": true,
"dependencies": {
"punycode": "^2.1.0"
}
@ -17582,11 +17690,6 @@
"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": {
"version": "9.1.0",
"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": {
"version": "5.1.2",
"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",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"peer": true,
"requires": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@ -21223,15 +21330,13 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"peer": true
"dev": true
},
"@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"peer": true,
"requires": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@ -21479,8 +21584,7 @@
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"dev": true,
"peer": true
"dev": true
},
"@types/json5": {
"version": "0.0.29",
@ -22279,7 +22383,6 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"peer": true,
"requires": {
"ajv": "^8.0.0"
},
@ -22289,7 +22392,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@ -22301,8 +22403,7 @@
"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,
"peer": true
"dev": true
}
}
},
@ -22878,6 +22979,11 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"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": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@ -23839,6 +23945,89 @@
"dev": 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": {
"version": "3.25.5",
"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",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"peer": true,
"requires": {
"path-type": "^4.0.0"
}
@ -25677,15 +25865,13 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"peer": true
"dev": true
},
"fast-glob": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
"integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
"dev": true,
"peer": true,
"requires": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@ -25733,7 +25919,6 @@
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
"integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
"dev": true,
"peer": true,
"requires": {
"reusify": "^1.0.4"
}
@ -26452,8 +26637,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
"dev": true,
"peer": true
"dev": true
},
"immutable": {
"version": "4.1.0",
@ -28954,8 +29138,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"peer": true
"dev": true
},
"methods": {
"version": "1.1.2",
@ -29617,8 +29800,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"peer": true
"dev": true
},
"pbkdf2": {
"version": "3.1.2",
@ -29964,8 +30146,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"peer": true
"dev": true
},
"quick-lru": {
"version": "4.0.1",
@ -30288,8 +30469,7 @@
"version": "2.0.2",
"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==",
"dev": true,
"peer": true
"dev": true
},
"requireindex": {
"version": "1.2.0",
@ -30367,8 +30547,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true,
"peer": true
"dev": true
},
"rfdc": {
"version": "1.3.0",
@ -30400,7 +30579,6 @@
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"peer": true,
"requires": {
"queue-microtask": "^1.2.2"
}
@ -30526,7 +30704,6 @@
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
"integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
"dev": true,
"peer": true,
"requires": {
"randombytes": "^2.1.0"
}
@ -31912,7 +32089,6 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
"integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
"dev": true,
"peer": true,
"requires": {
"punycode": "^2.1.0"
}
@ -32067,11 +32243,6 @@
"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": {
"version": "9.1.0",
"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-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": {
"version": "5.1.2",
"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/vue": "^7.4.0",
"@nextcloud/vue-richtext": "^2.0.4",
"blurhash": "^2.0.5",
"debounce": "^1.2.1",
"he": "^1.2.0",
"linkify-plugin-mention": "^4.1.0",
"linkify-string": "^4.1.0",
@ -53,8 +55,8 @@
"v-tooltip": "^4.0.0-beta.0",
"vue": "^2.7.10",
"vue-click-outside": "^1.0.7",
"vue-contenteditable-directive": "^1.2.0",
"vue-infinite-loading": "^2.4.4",
"vue-masonry-css": "^1.0.3",
"vue-material-design-icons": "^5.0.0",
"vue-router": "^3.6.5",
"vue-tribute": "^1.0.7",
@ -80,6 +82,7 @@
"@nextcloud/eslint-config": "^8.2.0",
"@nextcloud/stylelint-config": "^2.3.0",
"@nextcloud/webpack-vue-config": "^5.4.0",
"copy-webpack-plugin": "^11.0.0",
"cypress": "^11.2.0",
"jest": "^29.3.1",
"jest-serializer-vue": "^3.1.0",

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -26,6 +26,8 @@
<input id="file-upload"
ref="fileUploadInput"
type="file"
accept="image/*"
multiple="true"
tabindex="-1"
aria-hidden="true"
class="hidden-visually"
@ -47,8 +49,8 @@
<div v-if="replyTo" class="reply-to">
<p class="reply-info">
<span>{{ t('social', 'In reply to') }}</span>
<ActorAvatar :actor="replyTo.actor_info" :size="16" />
<strong>{{ replyTo.actor_info.account }}</strong>
<ActorAvatar :actor="replyTo.account" :size="16" />
<strong>{{ replyTo.account.acct }}</strong>
<NcButton type="tertiary"
class="close-button"
:aria-label="t('social', 'Close reply')"
@ -64,25 +66,24 @@
</div>
<form class="new-post-form" @submit.prevent="createPost">
<VueTribute :options="tributeOptions">
<!-- eslint-disable-next-line vue/valid-v-model -->
<div ref="composerInput"
v-contenteditable:post.dangerousHTML="canType && !loading"
:disabled="loading"
class="message"
placeholder="What would you like to share?"
:class="{'icon-loading': loading}"
@keyup.prevent.enter="keyup"
@input="updateStatusContent"
@tribute-replaced="updatePostFromTribute" />
</VueTribute>
<PreviewGrid :uploading="false"
:upload-progress="0.4"
:miniatures="previewUrls"
:miniatures="attachments"
@deleted="deletePreview" />
<div class="options">
<NcButton v-tooltip="t('social', 'Add attachment')"
type="tertiary"
:disabled="previewUrls.length >= 1"
:aria-label="t('social', 'Add attachment')"
@click.prevent="clickImportInput">
<template #icon>
@ -94,7 +95,7 @@
<NcEmojiPicker ref="emojiPicker"
:search="search"
:close-on-select="false"
:container="container"
container="#content-vue"
@select="insert">
<NcButton v-tooltip="t('social', 'Add emoji')"
type="tertiary"
@ -107,18 +108,11 @@
</NcEmojiPicker>
</div>
<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>
<VisibilitySelect :type.sync="type" />
<div class="emptySpace" />
<NcButton :value="currentVisibilityPostLabel"
<SubmitStatusButton :type="type" :disabled="canPost || loading" @click="createPost" />
<!-- <NcButton :value="currentVisibilityPostLabel"
:disabled="!canPost"
native-type="submit"
type="primary"
@ -127,7 +121,7 @@
<Send title="" :size="22" decorative />
</template>
{{ postTo }}
</NcButton>
</NcButton> -->
</div>
</form>
</div>
@ -136,12 +130,11 @@
<script>
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 FileUpload from 'vue-material-design-icons/FileUpload.vue'
import debounce from 'debounce'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.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 VueTribute from 'vue-tribute'
import he from 'he'
@ -151,11 +144,18 @@ import axios from '@nextcloud/axios'
import ActorAvatar from '../ActorAvatar.vue'
import { generateUrl } from '@nextcloud/router'
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 {
name: 'Composer',
components: {
NcPopoverMenu,
NcAvatar,
NcEmojiPicker,
NcButton,
@ -163,25 +163,24 @@ export default {
FileUpload,
VueTribute,
EmoticonOutline,
Send,
Close,
PreviewGrid,
VisibilitySelect,
SubmitStatusButton,
},
directives: {
FocusOnCreate,
},
mixins: [CurrentUserMixin],
props: {},
data() {
return {
statusContent: '',
type: localStorage.getItem('social.lastPostType') || 'followers',
loading: false,
post: '',
miniatures: [], // miniatures of images stored in postAttachments
postAttachments: [], // The toot's attachments
previewUrls: [],
canType: true,
/** @type {Object<string, LocalAttachment>} */
attachments: {},
search: '',
/** @type {import('../../types/Mastodon.js').Status} */
replyTo: null,
tributeOptions: {
spaceSelectsMatch: true,
@ -201,25 +200,23 @@ export default {
return '<span class="mention" contenteditable="false">'
+ '<a href="' + item.original.url + '" target="_blank"><img src="' + item.original.avatar + '" />@' + item.original.value + '</a></span>'
},
values: (text, cb) => {
const users = []
values: debounce(async (text, populate) => {
if (text.length < 1) {
cb(users)
populate([])
}
this.remoteSearchAccounts(text).then((result) => {
for (const i in result.data.result.accounts) {
const user = result.data.result.accounts[i]
users.push({
key: user.preferredUsername,
value: user.account,
url: user.url,
avatar: user.local ? generateUrl(`/avatar/${user.preferredUsername}/32`) : generateUrl(`apps/social/api/v1/global/actor/avatar?id=${user.id}`),
})
}
cb(users)
})
},
const response = await this.remoteSearchAccounts(text)
const users = response.data.result.accounts.map((user) => ({
key: user.preferredUsername,
value: user.account,
url: user.url,
avatar: user.local ? generateUrl(`/avatar/${user.preferredUsername}/32`) : generateUrl(`apps/social/api/v1/global/actor/avatar?id=${user.id}`),
}))
console.debug('[Composer] Found users for', text, response.data.result, users)
populate(users)
}, 200),
},
{
trigger: '#',
@ -237,29 +234,20 @@ export default {
return '<span class="hashtag" contenteditable="false">'
+ '<a href="' + generateUrl('/timeline/tags/' + tag) + '" target="_blank">#' + tag + '</a></span>'
},
values: (text, cb) => {
const tags = []
values: debounce(async (text, populate) => {
if (text.length < 1) {
cb(tags)
populate([])
}
this.remoteSearchHashtags(text).then((result) => {
if (result.data.result.exact) {
tags.push({
key: result.data.result.exact,
value: result.data.result.exact,
})
}
for (const i in result.data.result.tags) {
const tag = result.data.result.tags[i]
tags.push({
key: tag.hashtag,
value: tag.hashtag,
})
}
cb(tags)
})
},
const response = await this.remoteSearchHashtags(text)
const tags = [
...(response.data.result.exact && !Array.isArray(response.data.result.exact) ? [{ key: response.data.result.exact, value: response.data.result.exact }] : []),
...response.data.result.tags.map(({ hashtag }) => ({ key: hashtag, value: hashtag })),
]
console.debug('[Composer] Found tags for', text, response.data.result, tags)
populate(tags)
}, 200),
},
],
noMatchTemplate() {
@ -272,123 +260,15 @@ export default {
}
},
},
menuOpened: false,
}
},
computed: {
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 ''
},
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)
},
/** @return {boolean} */
canPost() {
if (this.previewUrls.length > 0) {
if (Object.keys(this.attachments).length > 0) {
return true
}
return this.post.length !== 0 && this.post !== '<br>'
return this.statusContent.length !== 0 && this.statusContent !== '<br>'
},
},
mounted() {
@ -398,95 +278,63 @@ export default {
})
},
methods: {
updateStatusContent() {
this.statusContent = this.$refs.composerInput.innerHTML
},
clickImportInput() {
this.$refs.fileUploadInput.click()
},
/** @param {InputEvent} event */
handleFileChange(event) {
event.target.files.forEach((file) => {
this.previewUrls.push({
description: '',
url: URL.createObjectURL(file),
result: file,
/** @type {HTMLInputElement} */
const target = event.target
Array.from(target.files).forEach(async (file) => {
const url = URL.createObjectURL(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) {
console.debug('[Composer] insert emoji', emoji)
if (typeof emoji === 'object') {
const category = Object.keys(emoji)[0]
const emojis = emoji[category]
const firstEmoji = Object.keys(emojis)[0]
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
const to = []
const mentionRegex = /<span class="mention"[^>]+><a[^>]+><img[^>]+>@([\w-_.]+@[\w-.]+)/g
let match = null
do {
match = mentionRegex.exec(contentHtml)
if (match) {
to.push(match[1])
if (lastChild === null) {
this.$refs.composerInput.innerHTML = div.innerHTML
} else {
// Content usually ends with </br> or </>
// This makes sure that we put the emoji before those tags.
switch (lastChild.tagName) {
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)
}
// 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
this.updateStatusContent()
},
keyup(event) {
if (event.shiftKey || event.ctrlKey) {
@ -494,45 +342,44 @@ export default {
}
},
updatePostFromTribute(event) {
// Trick to let vue-contenteditable know that tribute replaced a mention or hashtag
this.$refs.composerInput.oninput(event)
console.debug('[Composer] update from tribute', event)
this.updateStatusContent()
},
async createPost(event) {
const postData = this.getPostData()
// Trick to validate last mention when the user directly clicks on the "post" button without validating it.
const regex = /@([-\w]+)$/
const lastMention = postData.get('content').match(regex)
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')
// Replace emoji <img> tag with actual emojis.
// They will be replaced again with twemoji during rendering
const element = this.$refs.composerInput.cloneNode(true)
Array.from(element.getElementsByClassName('emoji')).forEach((emoji) => {
const em = document.createTextNode(emoji.getAttribute('alt'))
emoji.replaceWith(em)
})
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() {
this.replyTo = null
@ -540,13 +387,13 @@ export default {
this.$store.commit('setComposerDisplayStatus', false)
},
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) {
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) {
this.previewUrls.splice(index, 1)
deletePreview(key) {
this.$delete(this.attachments, key)
},
},
}

Wyświetl plik

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

Wyświetl plik

@ -1,87 +1,46 @@
<template>
<div class="preview-item-wrapper">
<div class="preview-item" :style="backgroundStyle">
<div class="preview-item">
<MediaAttachment :attachment="preview.data" />
<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>
<Close :size="16" fill-color="white" />
</template>
<span>{{ t('social', 'Delete') }}</span>
</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 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>
</template>
<script>
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 { translate } from '@nextcloud/l10n'
import MediaAttachment from '../MediaAttachment.vue'
export default {
name: 'PreviewGridItem',
components: {
Close,
// Edit,
// NcModal,
NcButton,
MediaAttachment,
},
props: {
/** @type {import('vue').PropType<import('./Composer.vue').LocalAttachment>} */
preview: {
type: Object,
required: true,
},
index: {
type: Number,
randomKey: {
type: String,
required: true,
},
},
data() {
return {
modal: false,
}
},
computed: {
backgroundStyle() {
return {
backgroundImage: `url("${this.preview.url}")`,
}
},
},
methods: {
showModal() {
this.modal = true
},
closeModal() {
this.modal = false
},
t: translate,
},
}
</script>
@ -95,7 +54,7 @@ export default {
.preview-item {
border-radius: var(--border-radius-large);
background-color: #000;
background: var(--color-background-darker);
background-position: 50%;
background-size: cover;
background-repeat: no-repeat;
@ -109,6 +68,9 @@ export default {
}
&__actions {
position: absolute;
top: 0;
width: 100%;
background: linear-gradient(180deg,rgba(0,0,0,.8),rgba(0,0,0,.35) 80%,transparent);
display: flex;
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 {
name: 'Emoji',
props: {
emoji: { type: String, default: '' },
},
data() {
return {}
emoji: {
type: String,
default: '',
},
},
computed: {
/**

Wyświetl plik

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

Wyświetl plik

@ -22,8 +22,8 @@
<template>
<!-- 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="isCurrentUserFollowing"
<div v-if="!serverData.public && relationship !== undefined">
<div v-if="relationship.following"
class="follow-button-container">
<NcButton :disabled="loading"
class="follow-button follow-button--following"
@ -87,16 +87,20 @@ export default {
}
},
computed: {
/** @return {boolean} */
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: {
async follow() {
try {
this.loading = true
await this.$store.dispatch('followAccount', { currentAccount: this.cloudId, accountToFollow: this.account })
} catch {
await this.$store.dispatch('followAccount', { currentAccount: this.cloudId, accountToFollow: this.profileAccount })
} finally {
this.loading = false
}
@ -104,8 +108,7 @@ export default {
async unfollow() {
try {
this.loading = true
await this.$store.dispatch('unfollowAccount', { currentAccount: this.cloudId, accountToUnfollow: this.account })
} catch {
await this.$store.dispatch('unfollowAccount', { currentAccount: this.cloudId, accountToUnfollow: this.profileAccount })
} finally {
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 Emoji from './Emoji.vue'
/**
* @typedef {object} MessageSource
* @property {Array} tag
* @property {string} content
*/
export default Vue.component('MessageContent', {
props: {
source: {
item: {
type: Object,
required: true,
},
},
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>.
*
* 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 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 {MessageSource} source
* @param {import('../types/Mastodon').Status} item
*/
export function formatMessage(createElement, source) {
if (!source.tag) {
source.tag = []
export function formatMessage(createElement, item) {
if (!item.tags) {
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 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 cleaned = cleanCopy(createElement, element, { mentions, hashtags })
const cleaned = cleanCopy(createElement, element, item)
return cleaned
}
@ -50,7 +41,7 @@ export function formatMessage(createElement, source) {
*
* @param {Function} createElement
* @param {HTMLElement} node
* @param {object} context
* @param {import('../types/Mastodon').Status} context
*/
function domToVue(createElement, node, context) {
switch (node.tagName) {
@ -133,7 +124,7 @@ function transformText(createElement, text) {
*
* @param {Function} createElement
* @param {HTMLElement} node
* @param {object} context
* @param {import('../types/Mastodon').Status} context
*/
function cleanCopy(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 {HTMLLinkElement} node
* @param {object} context
* @param {Array} context.mentions
* @param {import('../types/Mastodon').Status} context
*/
function cleanLink(createElement, node, context) {
const type = getLinkType(node.className)

Wyświetl plik

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

Wyświetl plik

@ -22,7 +22,7 @@
<template>
<div v-if="profileAccount && accountInfo" class="user-profile">
<NcAvatar v-if="accountInfo.local"
<NcAvatar v-if="isLocal"
:user="localUid"
:disable-tooltip="true"
:size="128" />
@ -32,32 +32,32 @@
:size="128" />
<h2>{{ displayName }}</h2>
<!-- 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>
<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>
</li>
<li>
<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>
</li>
<li>
<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>
</li>
</ul>
<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 v-if="accountInfo.website" class="user-profile__info">
{{ t('social', 'Website') }}: <a :href="accountInfo.website.value">{{ accountInfo.website.value }}</a>
<p v-if="website" class="user-profile__info">
{{ t('social', 'Website') }}: <a :href="website.value">{{ website.value }}</a>
</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"
class="user-profile__info primary"
@click="followRemote">
@ -72,9 +72,9 @@ import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import accountMixins from '../mixins/accountMixins.js'
import serverData from '../mixins/serverData.js'
import currentUser from '../mixins/currentUserMixin.js'
import follow from '../mixins/follow.js'
import FollowButton from './FollowButton.vue'
import { generateUrl } from '@nextcloud/router'
import { translate } from '@nextcloud/l10n'
export default {
name: 'ProfileInfo',
@ -87,7 +87,6 @@ export default {
accountMixins,
currentUser,
serverData,
follow,
],
props: {
uid: {
@ -101,31 +100,30 @@ export default {
}
},
computed: {
/** @return {string} */
localUid() {
// Returns only the local part of a username
return (this.uid.indexOf('@') === -1) ? this.uid : this.uid.slice(0, this.uid.indexOf('@'))
},
/** @return {string} */
displayName() {
if (typeof this.accountInfo.name !== 'undefined' && this.accountInfo.name !== '') {
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 this.accountInfo.display_name ?? this.accountInfo.username ?? this.profileAccount
},
/** @return {string} */
avatarUrl() {
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: {
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')
},
t: translate,
},
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -2,35 +2,40 @@
<div class="post-content">
<div class="post-header">
<div class="post-author-wrapper">
<router-link v-if="item.actor_info"
<!-- TODO -->
<router-link v-if="item.account"
: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">
{{ userDisplayName(item.actor_info) }}
{{ item.account.display_name }}
</span>
<span class="post-author-id">
@{{ item.actor_info.account }}
@{{ item.account.username }}
</span>
</router-link>
<a v-else :href="item.attributedTo">
<a v-else :href="item.account.id">
<span class="post-author-id">
{{ item.attributedTo }}
{{ item.account.id }}
</span>
</a>
</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 }}
</a>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="item.content" class="post-message">
<MessageContent :source="source" />
<MessageContent :item="item" />
</div>
<!-- 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">
<PostAttachment :attachments="item.attachment" />
<!-- TODO: clean media_attachments -->
<PostAttachment :attachments="item.media_attachments || []" />
</div>
<div v-if="$route && $route.params.type !== 'notifications' && !serverData.public" class="post-actions">
<NcButton v-tooltip="t('social', 'Reply')"
@ -64,7 +69,7 @@
</template>
</NcButton>
<NcActions>
<NcActionButton v-if="item.actor_info.account === cloudId"
<NcActionButton v-if="item.account !== undefined && item.account.acct === currentAccount.acct"
icon="icon-delete"
@click="remove()">
{{ 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 logger from '../services/logger.js'
import moment from '@nextcloud/moment'
import { generateUrl } from '@nextcloud/router'
import MessageContent from './MessageContent.js'
export default {
@ -108,47 +112,75 @@ export default {
},
mixins: [currentUser],
props: {
item: { type: Object, default: () => {} },
parentAnnounce: { type: Object, default: () => {} },
/** @type {import('vue').PropType<import('../types/Mastodon.js').Status>} */
item: {
type: Object,
default: () => {},
},
type: {
type: String,
required: true,
},
},
computed: {
/**
* @return {string}
*/
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() {
return Date.parse(this.item.published)
},
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 Date.parse(this.item.created_at)
},
/**
* @return {boolean}
*/
hasAttachments() {
return (typeof this.item.attachment !== 'undefined')
// TODO: clean media_attachments
return (this.item.media_attachments || []).length > 0
},
/**
* @return {boolean}
*/
isBoosted() {
if (typeof this.item.action === 'undefined') {
return false
}
return !!this.item.action.values.boosted
return this.item.reblogged === true
},
/**
* @return {boolean}
*/
isLiked() {
if (typeof this.item.action === 'undefined') {
return false
}
return !!this.item.action.values.liked
return this.item.favourited === true
},
/**
* @return {object}
*/
richParameters() {
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: {
/**
@ -158,21 +190,22 @@ export default {
*/
getSinglePostTimeline(e) {
// Display internal or external post
if (!this.item.local) {
if (this.item.type === 'Note') {
if (!this.isLocal) {
if (this.type === 'Note') {
window.open(this.item.id)
} else if (this.item.type === 'Announce') {
} else if (this.type === 'Announce') {
// TODO
window.open(this.item.object)
} 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 {
this.$router.push({
name: 'single-post',
params: {
account: this.item.actor_info.preferredUsername,
account: this.item.account.display_name,
id: this.item.id,
localId: this.item.id.split('/')[this.item.id.split('/').length - 1],
localId: this.item.uri.split('/').pop(),
type: 'single-post',
},
})
@ -188,7 +221,7 @@ export default {
boost() {
const params = {
post: this.item,
parentAnnounce: this.parentAnnounce,
parentAnnounce: this.reblog,
}
if (this.isBoosted) {
this.$store.dispatch('postUnBoost', params)
@ -202,7 +235,7 @@ export default {
like() {
const params = {
post: this.item,
parentAnnounce: this.parentAnnounce,
parentAnnounce: this.reblog,
}
if (this.isLiked) {
this.$store.dispatch('postUnlike', params)

Wyświetl plik

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

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

Wyświetl plik

@ -36,7 +36,7 @@ export default {
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() {
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
* where components would first show "user not found" before display an account's account info
*
* @return {boolean}
*/
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>
*
* @author Julius Härtl <jus@bitgrid.net>
@ -20,14 +20,17 @@
*
*/
import { getCurrentUser } from '@nextcloud/auth'
import serverData from './serverData.js'
export default {
mixins: [
serverData,
],
computed: {
currentUser() {
return OC.getCurrentUser()
return getCurrentUser()
},
socialId() {
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>
*
* @author Julius Härtl <jus@bitgrid.net>

Wyświetl plik

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

Wyświetl plik

@ -1,4 +1,4 @@
/*
/**
* @copyright Copyright (c) 2019 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',
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',
components: {

Wyświetl plik

@ -23,162 +23,234 @@
import axios from '@nextcloud/axios'
import { set } from 'vue'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import logger from '../services/logger.js'
const state = {
currentAccount: {},
currentAccount: '',
/** @type {Object<string, import('../types/Mastodon.js').Account>} */
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: {},
}
/**
* @param {typeof state} state
* @param {object} payload
* @param {string} payload.actorId
* @param {import('../types/Mastodon').Account} payload.data
*/
const addAccount = (state, { actorId, data }) => {
set(state.accounts, actorId, Object.assign({
followersList: [],
followingList: [],
details: {
following: false,
follower: false,
},
}, state.accounts[actorId], data))
set(state.accountIdMap, data.account, data.id)
set(state.accounts, actorId, { ...state.accounts[actorId], ...data })
set(state.accountsFollowers, actorId, [])
set(state.accountsFollowings, actorId, [])
const accountId = (data.acct.indexOf('@') === -1) ? data.acct + '@' + new URL(data.url).hostname : data.acct
set(state.accountIdMap, accountId, data.url)
}
const _getActorIdForAccount = (account) => state.accountIdMap[account]
/** @type {import('vuex').MutationTree<state, any>} */
const mutations = {
/**
* @param state
* @param {string} account
*/
setCurrentAccount(state, 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 })
},
/**
* @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 }) {
const users = []
for (const index in data) {
const actor = data[index].actor_info
if (typeof actor !== 'undefined' && account !== actor.account) {
users.push(actor.id)
addAccount(state, {
actorId: actor.id,
data: actor,
})
}
for (const actor of data) {
users.push(actor.url)
addAccount(state, {
actorId: actor.url,
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 }) {
const users = []
for (const index in data) {
const actor = data[index].actor_info
if (typeof actor !== 'undefined' && account !== actor.account) {
users.push(actor.id)
addAccount(state, {
actorId: actor.id,
data: actor,
})
}
for (const actor of data) {
users.push(actor.url)
addAccount(state, {
actorId: actor.url,
data: actor,
})
}
set(state.accounts[_getActorIdForAccount(account)], 'followingList', users)
set(state.accountsFollowings, _getActorIdForAccount(account), users)
},
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) {
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 = {
getAllAccounts(state) {
return (account) => { return state.accounts }
return () => { return state.accounts }
},
getAccount(state, getters) {
return (account) => {
return (/** @type {string} */ 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) {
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) {
return (account) => state.accounts[_getActorIdForAccount(account)]
return (/** @type {string} */ account) => state.accounts[_getActorIdForAccount(account)]
},
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) {
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() {
return _getActorIdForAccount
},
isFollowingUser(state) {
return (followingAccount) => {
const account = state.accounts[_getActorIdForAccount(followingAccount)]
return account && account.details ? account.details.following : false
}
return (/** @type {string} */ followingAccount) => state.accountsRelationships[_getActorIdForAccount(followingAccount)]?.following || false
},
}
/** @type {import('vuex').ActionTree<state, any>} */
const actions = {
fetchAccountInfo(context, account) {
return axios.get(generateUrl(`apps/social/api/v1/global/account/info?account=${account}`)).then((response) => {
context.commit('addAccount', { actorId: response.data.result.account.id, data: response.data.result.account })
return response.data.result.account
}).catch(() => {
OC.Notification.showTemporary(`Failed to load account details ${account}`)
})
async fetchAccountInfo(context, account) {
try {
const response = await axios.get(generateUrl(`apps/social/api/v1/global/account/info?account=${account}`))
context.commit('addAccount', { actorId: response.data.url, data: response.data })
return response.data
} catch (error) {
logger.error('Failed to load local account details', { error })
showError(`Failed to load local account details ${account}`)
}
},
fetchPublicAccountInfo(context, uid) {
return axios.get(generateUrl(`apps/social/api/v1/account/${uid}/info`)).then((response) => {
context.commit('addAccount', { actorId: response.data.result.account.id, data: response.data.result.account })
return response.data.result.account
}).catch(() => {
OC.Notification.showTemporary(`Failed to load account details ${uid}`)
})
async fetchAccountRelationshipInfo(context, ids) {
try {
const response = await axios.get(generateUrl('apps/social/api/v1/accounts/relationships'), { params: { id: ids } })
response.data.forEach(account => context.commit('addRelationship', { actorId: account.id, data: account }))
return response.data
} 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) {
commit('setCurrentAccount', account)
dispatch('fetchAccountInfo', account)
},
followAccount(context, { currentAccount, accountToFollow }) {
return axios.put(generateUrl('/apps/social/api/v1/current/follow?account=' + accountToFollow)).then((response) => {
async followAccount(context, { currentAccount, accountToFollow }) {
try {
const response = await axios.put(generateUrl('/apps/social/api/v1/current/follow?account=' + accountToFollow))
if (response.data.status === -1) {
return Promise.reject(response)
}
context.commit('followAccount', accountToFollow)
return Promise.resolve(response)
}).catch((error) => {
OC.Notification.showTemporary(`Failed to follow user ${accountToFollow}`)
console.error(`Failed to follow user ${accountToFollow}`, error)
})
return response
} catch (error) {
showError(`Failed to follow user ${accountToFollow}`)
logger.error(`Failed to follow user ${accountToFollow}`, { error })
}
},
unfollowAccount(context, { currentAccount, accountToUnfollow }) {
return axios.delete(generateUrl('/apps/social/api/v1/current/follow?account=' + accountToUnfollow)).then((response) => {
async unfollowAccount(context, { currentAccount, accountToUnfollow }) {
try {
const response = await axios.delete(generateUrl('/apps/social/api/v1/current/follow?account=' + accountToUnfollow))
if (response.data.status === -1) {
return Promise.reject(response)
}
context.commit('unfollowAccount', accountToUnfollow)
return Promise.resolve(response)
}).catch((error) => {
OC.Notification.showTemporary(`Failed to unfollow user ${accountToUnfollow}`)
console.error(`Failed to unfollow user ${accountToUnfollow}`, error.response.data)
return Promise.reject(error.response.data)
})
return response
} catch (error) {
showError(`Failed to unfollow user ${accountToUnfollow}`)
logger.error(`Failed to unfollow user ${accountToUnfollow}`, { error })
return error
}
},
fetchAccountFollowers(context, account) {
async fetchAccountFollowers(context, account) {
// TODO: fetching followers/following information of remotes is currently not supported
const parts = account.split('@')
const uid = (parts.length === 2 ? parts[0] : account)
axios.get(generateUrl(`apps/social/api/v1/account/${uid}/followers`)).then((response) => {
context.commit('addFollowers', { account, data: response.data.result })
})
try {
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
const parts = account.split('@')
const uid = (parts.length === 2 ? parts[0] : account)
axios.get(generateUrl(`apps/social/api/v1/account/${uid}/following`)).then((response) => {
context.commit('addFollowing', { account, data: response.data.result })
})
try {
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 { generateUrl } from '@nextcloud/router'
/**
* @property {object} timeline - The posts' collection
* @property {number} since - Time (EPOCH) of the most recent post
* @property {string} type - Timeline's type: 'home', 'single-post',...
* @property {object} params - Timeline's parameters
* @property {string} account -
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { showError } from '@nextcloud/dialogs'
import logger from '../services/logger.js'
const state = {
/**
* @type {Object<string, import('../types/Mastodon.js').Status>} timeline - The posts' collection
*/
timeline: {},
since: Math.floor(Date.now() / 1000) + 1,
/**
* @type {string} type - Timeline's type: 'home', 'single-post',...
*/
type: 'home',
/**
* @namespace params
* @property {string} account ???
* @property {string} id
* @property {string} localId
* @property {string} type ???
* @type {object} params - Timeline's parameters
* @property {string} params.account ???
* @property {string} params.id
* @property {string} params.localId
* @property {string} params.type ???
*/
params: {},
/**
* @type {string} 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.
*
* @member {boolean}
*/
composerDisplayStatus: false,
}
/** @type {import('vuex').MutationTree<state>} */
const mutations = {
/**
* @param state
* @param {import('../types/Mastodon.js').Status[]} data
*/
addToTimeline(state, data) {
for (const item in data) {
state.since = data[item].publishedTime
Vue.set(state.timeline, data[item].id, data[item])
// TODO: fix to handle ancestors
if (data.descendants) {
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) {
Vue.delete(state.timeline, post.id)
},
resetTimeline(state) {
state.timeline = {}
state.since = Math.floor(Date.now() / 1000) + 1
},
/**
* @param state
* @param {string} type
*/
setTimelineType(state, type) {
state.type = type
},
setTimelineParams(state, params) {
state.params = params
},
/**
* @param state
* @param {boolean} status
*/
setComposerDisplayStatus(state, status) {
state.composerDisplayStatus = status
},
/**
* @param state
* @param {string} account
*/
setAccount(state, 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 }) {
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') {
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 }) {
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') {
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 }) {
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') {
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 }) {
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') {
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 = {
getComposerDisplayStatus(state) {
return state.composerDisplayStatus
},
getTimeline(state) {
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) {
@ -132,6 +186,8 @@ const getters = {
}
},
}
/** @type {import('vuex').ActionTree<state, any>} */
const actions = {
changeTimelineType(context, { type, params }) {
context.commit('resetTimeline')
@ -144,108 +200,183 @@ const actions = {
context.commit('setTimelineType', 'account')
context.commit('setAccount', account)
},
async post(context, post) {
/**
* @param context
* @param {File} file
*/
async createMedia(context, file) {
try {
const { data } = await axios.post(generateUrl('apps/social/api/v1/post'), post, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
logger.info('Post created with token ' + data.result.token)
const formData = new FormData()
formData.append('file', file)
const { data } = await axios.post(
generateUrl('apps/social/api/v1/media'),
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
logger.info('Media created with id ' + data.id)
return data
} catch (error) {
OC.Notification.showTemporary('Failed to create a post')
logger.error('Failed to create a post', { error: error.response })
showError('Failed to create a media')
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) {
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)
logger.info('Post deleted with token ' + response.data.result.token)
}).catch((error) => {
OC.Notification.showTemporary('Failed to delete the post')
showError('Failed to delete the post')
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 }) {
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 })
resolve(response)
}).catch((error) => {
OC.Notification.showTemporary('Failed to like post')
logger.error('Failed to like post', { error: error.response })
showError('Failed to like post')
logger.error('Failed to like post', { error })
reject(error)
})
})
},
/**
* @param context
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.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 })
// Remove post from list if we are in the 'liked' timeline
if (state.type === 'liked') {
context.commit('removePost', post)
}
}).catch((error) => {
OC.Notification.showTemporary('Failed to unlike post')
showError('Failed to unlike post')
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 }) {
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 })
logger.info('Post boosted with token ' + response.data.result.token)
resolve(response)
}).catch((error) => {
OC.Notification.showTemporary('Failed to create a boost post')
logger.error('Failed to create a boost post', { error: error.response })
showError('Failed to create a boost post')
logger.error('Failed to create a boost post', { error })
reject(error)
})
})
},
/**
* @param context
* @param {object} root0
* @param {import('../types/Mastodon.js').Status} root0.post
* @param {object} root0.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 })
logger.info('Boost deleted with token ' + response.data.result.token)
}).catch((error) => {
OC.Notification.showTemporary('Failed to delete the boost')
showError('Failed to delete the boost')
logger.error('Failed to delete the boost', { error })
})
},
refreshTimeline(context) {
return this.dispatch('fetchTimeline', { sinceTimestamp: Math.floor(Date.now() / 1000) + 1 })
return this.dispatch('fetchTimeline')
},
fetchTimeline(context, { sinceTimestamp }) {
if (typeof sinceTimestamp === 'undefined') {
sinceTimestamp = state.since - 1
/**
*
* @param {object} context
* @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 = ''
if (state.type === 'account') {
url = generateUrl(`apps/social/api/v1/account/${state.account}/stream?limit=25&since=` + sinceTimestamp)
} else if (state.type === 'tags') {
url = generateUrl(`apps/social/api/v1/stream/tag/${state.params.tag}?limit=25&since=` + sinceTimestamp)
} else if (state.type === 'single-post') {
url = generateUrl(`apps/social/local/v1/post/replies?id=${state.params.id}&limit=5&since=` + sinceTimestamp)
} else {
url = generateUrl(`apps/social/api/v1/stream/${state.type}?limit=25&since=` + sinceTimestamp)
switch (state.type) {
case 'account':
url = generateUrl(`apps/social/api/v1/accounts/${state.account}/statuses`)
break
case 'tags':
url = generateUrl(`apps/social/api/v1/timelines/tag/${state.params.tag}`)
break
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
return axios.get(url).then((response) => {
const response = await axios.get(url, { params })
if (response.status === -1) {
throw response.message
}
// Add results to timeline
context.commit('addToTimeline', response.data)
// Add results to timeline
context.commit('addToTimeline', response.data.result)
return response.data
})
return response.data
},
/**
* @param context
* @param {import('../types/Mastodon.js').Status[]} data
*/
addToTimeline(context, 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: {
/** @return {object[]} */
items() {
return this.notifications.map((n) => {
return {
@ -88,11 +89,13 @@ export default {
}
})
},
/** @return {number} */
lastTimestamp() {
return this.notifications.length
? this.notifications[0].publishedTime
: 0
},
/** @return {string} */
emptyContentMessage() {
if (this.state === 'error') {
return t('social', 'Error getting Social notifications')
@ -101,6 +104,7 @@ export default {
}
return ''
},
/** @return {string} */
emptyContentIcon() {
if (this.state === 'error') {
return 'icon-close'

Wyświetl plik

@ -24,7 +24,7 @@
<div :class="{'icon-loading': !accountLoaded}" class="social__wrapper">
<ProfileInfo v-if="accountLoaded && accountInfo" :uid="uid" />
<!-- 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"
:title="t('social', 'User not found')"
:description="t('social', 'Sorry, we could not find the account of {userId}', { userId: uid })">
@ -57,19 +57,22 @@ export default {
data() {
return {
state: [],
/** @type {string|null} */
uid: null,
}
},
computed: {
/** @return {import('../types/Mastodon').Status[]} */
timeline() {
return this.$store.getters.getTimeline
},
/** @return {string} */
emptyContentImage() {
return generateFilePath('social', 'img', 'undraw/profile.svg')
},
},
// Start fetching account information before mounting the component
beforeMount() {
async beforeMount() {
this.uid = this.$route.params.account || this.serverData.account
// 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,
// 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) => {
this.uid = response.account
})
/** @type {[import('../types/Mastodon').Account]} */
const response = await this.$store.dispatch(fetchMethod, this.profileAccount)
this.uid = response.acct
await this.$store.dispatch('fetchAccountRelationshipInfo', [this.accountInfo.id])
},
}
</script>

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -2,7 +2,7 @@
<div class="social__wrapper">
<ProfileInfo v-if="accountLoaded && accountInfo" :uid="uid" />
<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" />
</div>
</template>
@ -72,14 +72,14 @@ export default {
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,
// 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
const params = {
account: this.account,
id: window.location.href,
localId: window.location.href.split('/')[window.location.href.split('/').length - 1],
localId: this.mainPost.id,
type: 'single-post',
}
this.$store.dispatch('changeTimelineType', {

Wyświetl plik

@ -1,8 +1,15 @@
// SPDX-FileCopyrigthText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
const path = require('path');
const path = require('path')
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 = {
social: path.join(__dirname, 'src', 'main.js'),