Skip to content
GitLab
Menu
Projects
Groups
Snippets
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
Ad Aures
Castopod
Commits
9e1e5d2e
Commit
9e1e5d2e
authored
Jul 12, 2021
by
Yassine Doghri
Browse files
feat(activitypub): add Podcast actor and PodcastEpisode object with comments
parent
b814cfaf
Pipeline
#974
passed with stages
in 9 minutes and 1 second
Changes
13
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
app/Config/ActivityPub.php
View file @
9e1e5d2e
...
@@ -6,7 +6,6 @@ namespace Config;
...
@@ -6,7 +6,6 @@ namespace Config;
use
ActivityPub\Config\ActivityPub
as
ActivityPubBase
;
use
ActivityPub\Config\ActivityPub
as
ActivityPubBase
;
use
App\Libraries\NoteObject
;
use
App\Libraries\NoteObject
;
use
App\Libraries\PodcastActor
;
class
ActivityPub
extends
ActivityPubBase
class
ActivityPub
extends
ActivityPubBase
{
{
...
@@ -15,8 +14,6 @@ class ActivityPub extends ActivityPubBase
...
@@ -15,8 +14,6 @@ class ActivityPub extends ActivityPubBase
* ActivityPub Objects
* ActivityPub Objects
* --------------------------------------------------------------------
* --------------------------------------------------------------------
*/
*/
public
string
$actorObject
=
PodcastActor
::
class
;
public
string
$noteObject
=
NoteObject
::
class
;
public
string
$noteObject
=
NoteObject
::
class
;
/**
/**
...
...
app/Config/Routes.php
View file @
9e1e5d2e
...
@@ -697,6 +697,10 @@ $routes->group('@(:podcastName)', function ($routes): void {
...
@@ -697,6 +697,10 @@ $routes->group('@(:podcastName)', function ($routes): void {
'namespace'
=>
'ActivityPub\Controllers'
,
'namespace'
=>
'ActivityPub\Controllers'
,
'controller-method'
=>
'ActorController/$1'
,
'controller-method'
=>
'ActorController/$1'
,
],
],
'application/podcast-activity+json'
=>
[
'namespace'
=>
'App\Controllers'
,
'controller-method'
=>
'PodcastController::podcastActor/$1'
,
],
'application/ld+json; profile="https://www.w3.org/ns/activitystreams'
=>
[
'application/ld+json; profile="https://www.w3.org/ns/activitystreams'
=>
[
'namespace'
=>
'ActivityPub\Controllers'
,
'namespace'
=>
'ActivityPub\Controllers'
,
'controller-method'
=>
'ActorController/$1'
,
'controller-method'
=>
'ActorController/$1'
,
...
@@ -705,10 +709,44 @@ $routes->group('@(:podcastName)', function ($routes): void {
...
@@ -705,10 +709,44 @@ $routes->group('@(:podcastName)', function ($routes): void {
]);
]);
$routes
->
get
(
'episodes'
,
'PodcastController::episodes/$1'
,
[
$routes
->
get
(
'episodes'
,
'PodcastController::episodes/$1'
,
[
'as'
=>
'podcast-episodes'
,
'as'
=>
'podcast-episodes'
,
'alternate-content'
=>
[
'application/activity+json'
=>
[
'controller-method'
=>
'PodcastController::episodeCollection/$1'
,
],
'application/podcast-activity+json'
=>
[
'controller-method'
=>
'PodcastController::episodeCollection/$1'
,
],
'application/ld+json; profile="https://www.w3.org/ns/activitystreams'
=>
[
'controller-method'
=>
'PodcastController::episodeCollection/$1'
,
],
],
]);
]);
$routes
->
group
(
'episodes/(:slug)'
,
function
(
$routes
):
void
{
$routes
->
group
(
'episodes/(:slug)'
,
function
(
$routes
):
void
{
$routes
->
get
(
'/'
,
'EpisodeController/$1/$2'
,
[
$routes
->
get
(
'/'
,
'EpisodeController/$1/$2'
,
[
'as'
=>
'episode'
,
'as'
=>
'episode'
,
'alternate-content'
=>
[
'application/activity+json'
=>
[
'controller-method'
=>
'EpisodeController::episodeObject/$1/$2'
,
],
'application/podcast-activity+json'
=>
[
'controller-method'
=>
'EpisodeController::episodeObject/$1/$2'
,
],
'application/ld+json; profile="https://www.w3.org/ns/activitystreams'
=>
[
'controller-method'
=>
'EpisodeController::episodeObject/$1/$2'
,
],
],
]);
$routes
->
get
(
'comments'
,
'EpisodeController::comments/$1/$2'
,
[
'as'
=>
'episode-comments'
,
'application/activity+json'
=>
[
'controller-method'
=>
'EpisodeController::comments/$1/$2'
,
],
'application/podcast-activity+json'
=>
[
'controller-method'
=>
'EpisodeController::comments/$1/$2'
,
],
'application/ld+json; profile="https://www.w3.org/ns/activitystreams'
=>
[
'controller-method'
=>
'EpisodeController::comments/$1/$2'
,
],
]);
]);
$routes
->
get
(
'oembed.json'
,
'EpisodeController::oembedJSON/$1/$2'
,
[
$routes
->
get
(
'oembed.json'
,
'EpisodeController::oembedJSON/$1/$2'
,
[
'as'
=>
'episode-oembed-json'
,
'as'
=>
'episode-oembed-json'
,
...
...
app/Controllers/EpisodeController.php
View file @
9e1e5d2e
...
@@ -10,12 +10,18 @@ declare(strict_types=1);
...
@@ -10,12 +10,18 @@ declare(strict_types=1);
namespace
App\Controllers
;
namespace
App\Controllers
;
use
ActivityPub\Objects\OrderedCollectionObject
;
use
ActivityPub\Objects\OrderedCollectionPage
;
use
Analytics\AnalyticsTrait
;
use
Analytics\AnalyticsTrait
;
use
App\Entities\Episode
;
use
App\Entities\Episode
;
use
App\Entities\Podcast
;
use
App\Entities\Podcast
;
use
App\Libraries\NoteObject
;
use
App\Libraries\PodcastEpisode
;
use
App\Models\EpisodeModel
;
use
App\Models\EpisodeModel
;
use
App\Models\PodcastModel
;
use
App\Models\PodcastModel
;
use
CodeIgniter\Database\BaseBuilder
;
use
CodeIgniter\Exceptions\PageNotFoundException
;
use
CodeIgniter\Exceptions\PageNotFoundException
;
use
CodeIgniter\HTTP\Response
;
use
CodeIgniter\HTTP\ResponseInterface
;
use
CodeIgniter\HTTP\ResponseInterface
;
use
Config\Services
;
use
Config\Services
;
use
SimpleXMLElement
;
use
SimpleXMLElement
;
...
@@ -191,4 +197,59 @@ class EpisodeController extends BaseController
...
@@ -191,4 +197,59 @@ class EpisodeController extends BaseController
return
$this
->
response
->
setXML
((
string
)
$oembed
);
return
$this
->
response
->
setXML
((
string
)
$oembed
);
}
}
/**
* @noRector ReturnTypeDeclarationRector
*/
public
function
episodeObject
():
Response
{
$podcastObject
=
new
PodcastEpisode
(
$this
->
episode
);
return
$this
->
response
->
setContentType
(
'application/json'
)
->
setBody
(
$podcastObject
->
toJSON
());
}
/**
* @noRector ReturnTypeDeclarationRector
*/
public
function
comments
():
Response
{
/**
* get comments: aggregated replies from posts referring to the episode
*/
$episodeComments
=
model
(
'StatusModel'
)
->
whereIn
(
'in_reply_to_id'
,
function
(
BaseBuilder
$builder
):
BaseBuilder
{
return
$builder
->
select
(
'id'
)
->
from
(
'activitypub_statuses'
)
->
where
(
'episode_id'
,
$this
->
episode
->
id
);
})
->
where
(
'`published_at` <= NOW()'
,
null
,
false
)
->
orderBy
(
'published_at'
,
'ASC'
);
$pageNumber
=
(
int
)
$this
->
request
->
getGet
(
'page'
);
if
(
$pageNumber
<
1
)
{
$episodeComments
->
paginate
(
12
);
$pager
=
$episodeComments
->
pager
;
$collection
=
new
OrderedCollectionObject
(
null
,
$pager
);
}
else
{
$paginatedComments
=
$episodeComments
->
paginate
(
12
,
'default'
,
$pageNumber
);
$pager
=
$episodeComments
->
pager
;
$orderedItems
=
[];
if
(
$paginatedComments
!==
null
)
{
foreach
(
$paginatedComments
as
$comment
)
{
$orderedItems
[]
=
(
new
NoteObject
(
$comment
))
->
toArray
();
}
}
// @phpstan-ignore-next-line
$collection
=
new
OrderedCollectionPage
(
$pager
,
$orderedItems
);
}
return
$this
->
response
->
setContentType
(
'application/activity+json'
)
->
setBody
(
$collection
->
toJSON
());
}
}
}
app/Controllers/PodcastController.php
View file @
9e1e5d2e
...
@@ -10,12 +10,18 @@ declare(strict_types=1);
...
@@ -10,12 +10,18 @@ declare(strict_types=1);
namespace
App\Controllers
;
namespace
App\Controllers
;
use
ActivityPub\Objects\OrderedCollectionObject
;
use
ActivityPub\Objects\OrderedCollectionPage
;
use
Analytics\AnalyticsTrait
;
use
Analytics\AnalyticsTrait
;
use
App\Entities\Podcast
;
use
App\Entities\Podcast
;
use
App\Libraries\PodcastActor
;
use
App\Libraries\PodcastEpisode
;
use
App\Models\EpisodeModel
;
use
App\Models\EpisodeModel
;
use
App\Models\PodcastModel
;
use
App\Models\PodcastModel
;
use
App\Models\StatusModel
;
use
App\Models\StatusModel
;
use
CodeIgniter\Exceptions\PageNotFoundException
;
use
CodeIgniter\Exceptions\PageNotFoundException
;
use
CodeIgniter\HTTP\RedirectResponse
;
use
CodeIgniter\HTTP\Response
;
class
PodcastController
extends
BaseController
class
PodcastController
extends
BaseController
{
{
...
@@ -42,6 +48,15 @@ class PodcastController extends BaseController
...
@@ -42,6 +48,15 @@ class PodcastController extends BaseController
return
$this
->
{
$method
}(
...
$params
);
return
$this
->
{
$method
}(
...
$params
);
}
}
public
function
podcastActor
():
RedirectResponse
{
$podcastActor
=
new
PodcastActor
(
$this
->
podcast
);
return
$this
->
response
->
setContentType
(
'application/activity+json'
)
->
setBody
(
$podcastActor
->
toJSON
());
}
public
function
activity
():
string
public
function
activity
():
string
{
{
// Prevent analytics hit when authenticated
// Prevent analytics hit when authenticated
...
@@ -209,4 +224,46 @@ class PodcastController extends BaseController
...
@@ -209,4 +224,46 @@ class PodcastController extends BaseController
return
$cachedView
;
return
$cachedView
;
}
}
/**
* @noRector ReturnTypeDeclarationRector
*/
public
function
episodeCollection
():
Response
{
if
(
$this
->
podcast
->
type
===
'serial'
)
{
// podcast is serial
$episodes
=
model
(
'EpisodeModel'
)
->
where
(
'`published_at` <= NOW()'
,
null
,
false
)
->
orderBy
(
'season_number DESC, number ASC'
);
}
else
{
$episodes
=
model
(
'EpisodeModel'
)
->
where
(
'`published_at` <= NOW()'
,
null
,
false
)
->
orderBy
(
'published_at'
,
'DESC'
);
}
$pageNumber
=
(
int
)
$this
->
request
->
getGet
(
'page'
);
if
(
$pageNumber
<
1
)
{
$episodes
->
paginate
(
12
);
$pager
=
$episodes
->
pager
;
$collection
=
new
OrderedCollectionObject
(
null
,
$pager
);
}
else
{
$paginatedEpisodes
=
$episodes
->
paginate
(
12
,
'default'
,
$pageNumber
);
$pager
=
$episodes
->
pager
;
$orderedItems
=
[];
if
(
$paginatedEpisodes
!==
null
)
{
foreach
(
$paginatedEpisodes
as
$episode
)
{
$orderedItems
[]
=
(
new
PodcastEpisode
(
$episode
))
->
toArray
();
}
}
// @phpstan-ignore-next-line
$collection
=
new
OrderedCollectionPage
(
$pager
,
$orderedItems
);
}
return
$this
->
response
->
setContentType
(
'application/activity+json'
)
->
setBody
(
$collection
->
toJSON
());
}
}
}
app/Entities/Episode.php
View file @
9e1e5d2e
...
@@ -121,6 +121,11 @@ class Episode extends Entity
...
@@ -121,6 +121,11 @@ class Episode extends Entity
*/
*/
protected
?array
$statuses
=
null
;
protected
?array
$statuses
=
null
;
/**
* @var Status[]|null
*/
protected
?array
$comments
=
null
;
protected
?Location
$location
=
null
;
protected
?Location
$location
=
null
;
protected
string
$custom_rss_string
;
protected
string
$custom_rss_string
;
...
@@ -387,7 +392,7 @@ class Episode extends Entity
...
@@ -387,7 +392,7 @@ class Episode extends Entity
public
function
getStatuses
():
array
public
function
getStatuses
():
array
{
{
if
(
$this
->
id
===
null
)
{
if
(
$this
->
id
===
null
)
{
throw
new
RuntimeException
(
'Episode must be created before getting s
oundbit
es.'
);
throw
new
RuntimeException
(
'Episode must be created before getting s
tatus
es.'
);
}
}
if
(
$this
->
statuses
===
null
)
{
if
(
$this
->
statuses
===
null
)
{
...
@@ -397,6 +402,22 @@ class Episode extends Entity
...
@@ -397,6 +402,22 @@ class Episode extends Entity
return
$this
->
statuses
;
return
$this
->
statuses
;
}
}
/**
* @return Status[]
*/
public
function
getComments
():
array
{
if
(
$this
->
id
===
null
)
{
throw
new
RuntimeException
(
'Episode must be created before getting comments.'
);
}
if
(
$this
->
comments
===
null
)
{
$this
->
comments
=
(
new
StatusModel
())
->
getEpisodeComments
(
$this
->
id
);
}
return
$this
->
comments
;
}
public
function
getLink
():
string
public
function
getLink
():
string
{
{
return
base_url
(
route_to
(
'episode'
,
$this
->
getPodcast
()
->
name
,
$this
->
attributes
[
'slug'
]));
return
base_url
(
route_to
(
'episode'
,
$this
->
getPodcast
()
->
name
,
$this
->
attributes
[
'slug'
]));
...
...
app/Libraries/ActivityPub/Controllers/StatusController.php
View file @
9e1e5d2e
...
@@ -92,7 +92,7 @@ class StatusController extends Controller
...
@@ -92,7 +92,7 @@ class StatusController extends Controller
if
(
$paginatedReplies
!==
null
)
{
if
(
$paginatedReplies
!==
null
)
{
foreach
(
$paginatedReplies
as
$reply
)
{
foreach
(
$paginatedReplies
as
$reply
)
{
$replyObject
=
new
$noteObjectClass
(
$reply
);
$replyObject
=
new
$noteObjectClass
(
$reply
);
$orderedItems
[]
=
$replyObject
->
to
JSON
();
$orderedItems
[]
=
$replyObject
->
to
Array
();
}
}
}
}
...
...
app/Libraries/ActivityPub/Objects/NoteObject.php
View file @
9e1e5d2e
...
@@ -39,7 +39,7 @@ class NoteObject extends ObjectType
...
@@ -39,7 +39,7 @@ class NoteObject extends ObjectType
$this
->
inReplyTo
=
$status
->
reply_to_status
->
uri
;
$this
->
inReplyTo
=
$status
->
reply_to_status
->
uri
;
}
}
$this
->
replies
=
base_url
(
route
_to
(
'status-replies'
,
$status
->
actor
->
username
,
$status
->
id
)
)
;
$this
->
replies
=
url
_to
(
'status-replies'
,
$status
->
actor
->
username
,
$status
->
id
);
$this
->
cc
=
[
$status
->
actor
->
followers_url
];
$this
->
cc
=
[
$status
->
actor
->
followers_url
];
}
}
...
...
app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php
View file @
9e1e5d2e
...
@@ -28,7 +28,7 @@ class OrderedCollectionObject extends ObjectType
...
@@ -28,7 +28,7 @@ class OrderedCollectionObject extends ObjectType
protected
?string
$last
=
null
;
protected
?string
$last
=
null
;
/**
/**
* @param ObjectType[] $orderedItems
* @param ObjectType[]
|null
$orderedItems
*/
*/
public
function
__construct
(
public
function
__construct
(
protected
?array
$orderedItems
=
null
,
protected
?array
$orderedItems
=
null
,
...
...
app/Libraries/Analytics/AnalyticsTrait.php
View file @
9e1e5d2e
...
@@ -40,7 +40,7 @@ trait AnalyticsTrait
...
@@ -40,7 +40,7 @@ trait AnalyticsTrait
$procedureName
=
$db
->
prefixTable
(
'analytics_website'
);
$procedureName
=
$db
->
prefixTable
(
'analytics_website'
);
$db
->
query
(
"call
{
$procedureName
}
(?,?,?,?,?,?)"
,
[
$db
->
query
(
"call
{
$procedureName
}
(?,?,?,?,?,?)"
,
[
$podcastId
,
$podcastId
,
$session
->
get
(
'browser'
),
$session
->
get
(
'browser'
)
??
''
,
$session
->
get
(
'entryPage'
),
$session
->
get
(
'entryPage'
),
$referer
,
$referer
,
$domain
,
$domain
,
...
...
app/Libraries/PodcastActor.php
View file @
9e1e5d2e
...
@@ -10,21 +10,39 @@ declare(strict_types=1);
...
@@ -10,21 +10,39 @@ declare(strict_types=1);
namespace
App\Libraries
;
namespace
App\Libraries
;
use
ActivityPub\Entities\Actor
;
use
ActivityPub\Objects\ActorObject
;
use
ActivityPub\Objects\ActorObject
;
use
App\
Model
s\Podcast
Model
;
use
App\
Entitie
s\Podcast
;
class
PodcastActor
extends
ActorObject
class
PodcastActor
extends
ActorObject
{
{
protected
string
$rss
;
protected
string
$rss
Feed
;
public
function
__construct
(
Actor
$actor
)
protected
string
$language
;
protected
string
$category
;
protected
string
$episodes
;
public
function
__construct
(
Podcast
$podcast
)
{
{
parent
::
__construct
(
$actor
);
parent
::
__construct
(
$podcast
->
actor
);
$this
->
context
[]
=
'https://github.com/Podcastindex-org/activitypub-spec-work/blob/main/docs/1.0.md'
;
$this
->
type
=
'Podcast'
;
$this
->
rssFeed
=
$podcast
->
feed_url
;
$this
->
language
=
$podcast
->
language_code
;
$category
=
''
;
if
(
$podcast
->
category
->
parent_id
!==
null
)
{
$category
.
=
$podcast
->
category
->
parent
->
apple_category
.
' > '
;
}
$category
.
=
$podcast
->
category
->
apple_category
;
$podcast
=
(
new
PodcastModel
())
->
where
(
'actor_id'
,
$actor
->
id
)
$this
->
category
=
$category
;
->
first
();
$this
->
rss
=
$podcast
->
feed_url
;
$this
->
episodes
=
url_to
(
'podcast-episodes'
,
$podcast
->
name
)
;
}
}
}
}
app/Libraries/PodcastEpisode.php
0 → 100644
View file @
9e1e5d2e
<?php
declare
(
strict_types
=
1
);
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace
App\Libraries
;
use
ActivityPub\Core\ObjectType
;
use
App\Entities\Episode
;
class
PodcastEpisode
extends
ObjectType
{
protected
string
$type
=
'PodcastEpisode'
;
protected
string
$attributedTo
;
protected
string
$comments
;
/**
* @var array<mixed>
*/
protected
array
$description
=
[];
/**
* @var array<string, string>
*/
protected
array
$image
=
[];
/**
* @var array<mixed>
*/
protected
array
$audio
=
[];
public
function
__construct
(
Episode
$episode
)
{
// TODO: clean things up with specified spec
$this
->
id
=
$episode
->
link
;
$this
->
description
=
[
'type'
=>
'Note'
,
'mediaType'
=>
'text/markdown'
,
'content'
=>
$episode
->
description_markdown
,
'contentMap'
=>
[
$episode
->
podcast
->
language_code
=>
$episode
->
description_html
,
],
];
$this
->
image
=
[
'type'
=>
'Image'
,
'mediaType'
=>
$episode
->
image_mimetype
,
'url'
=>
$episode
->
image
->
url
,
];
// add audio file
$this
->
audio
=
[
'id'
=>
$episode
->
audio_file_url
,
'type'
=>
'Audio'
,
'name'
=>
$episode
->
title
,
'size'
=>
$episode
->
audio_file_size
,
'duration'
=>
$episode
->
audio_file_duration
,
'url'
=>
[
'href'
=>
$episode
->
audio_file_url
,
'type'
=>
'Link'
,
'mediaType'
=>
$episode
->
audio_file_mimetype
,
],
'transcript'
=>
$episode
->
transcript_file_url
,
'chapters'
=>
$episode
->
chapters_file_url
,
];
$this
->
comments
=
url_to
(
'episode-comments'
,
$episode
->
podcast
->
name
,
$episode
->
slug
);
if
(
$episode
->
published_at
!==
null
)
{
$this
->
published
=
$episode
->
published_at
->
format
(
DATE_W3C
);
}
if
(
$episode
->
podcast
->
actor
!==
null
)
{
$this
->
attributedTo
=
$episode
->
podcast
->
actor
->
uri
;
if
(
$episode
->
podcast
->
actor
->
followers_url
)
{
$this
->
cc
=
[
$episode
->
podcast
->
actor
->
followers_url
];
}
}
}
}
app/Models/StatusModel.php
View file @
9e1e5d2e
...
@@ -12,6 +12,7 @@ namespace App\Models;
...
@@ -12,6 +12,7 @@ namespace App\Models;
use
ActivityPub\Models\StatusModel
as
ActivityPubStatusModel
;
use
ActivityPub\Models\StatusModel
as
ActivityPubStatusModel
;
use
App\Entities\Status
;
use
App\Entities\Status
;
use
CodeIgniter\Database\BaseBuilder
;
class
StatusModel
extends
ActivityPubStatusModel
class
StatusModel
extends
ActivityPubStatusModel
{
{
...
@@ -53,4 +54,21 @@ class StatusModel extends ActivityPubStatusModel
...
@@ -53,4 +54,21 @@ class StatusModel extends ActivityPubStatusModel
->
orderBy
(
'published_at'
,
'DESC'
)
->
orderBy
(
'published_at'
,
'DESC'
)
->
findAll
();
->
findAll
();
}
}
/**
* Retrieves all published statuses for a given episode ordered by publication date
*
* @return Status[]
*/
public
function
getEpisodeComments
(
int
$episodeId
):
array
{
return
$this
->
whereIn
(
'in_reply_to_id'
,
function
(
BaseBuilder
$builder
)
use
(
&
$episodeId
):
BaseBuilder
{
return
$builder
->
select
(
'id'
)
->
from
(
'activitypub_statuses'
)
->
where
(
'episode_id'
,
$episodeId
);
})
->
where
(
'`published_at` <= NOW()'
,
null
,
false
)
->
orderBy
(
'published_at'
,
'ASC'
)
->
findAll
();
}
}
}
app/Views/admin/podcast/latest_episodes.php
View file @
9e1e5d2e
...
@@ -10,7 +10,7 @@
...
@@ -10,7 +10,7 @@
</a>
</a>
</header>
</header>
<?php
if
(
$episodes
)
:
?>
<?php
if
(
$episodes
)
:
?>
<div
class=
"flex
justify-between p-2 space-x-4
overflow-x-auto"
>
<div
class=
"flex
p-2
overflow-x-auto
gap-x-6
"
>
<?php
foreach
(
$episodes
as
$episode
)
:
?>
<?php
foreach
(
$episodes
as
$episode
)
:
?>
<article
class=
"flex flex-col flex-shrink-0 w-56 overflow-hidden bg-white border shadow rounded-xl"
>
<article
class=
"flex flex-col flex-shrink-0 w-56 overflow-hidden bg-white border shadow rounded-xl"
>
<img
<img
...
...
Yassine Doghri
@yassine
mentioned in commit
7047d5af
·
Jul 12, 2021
mentioned in commit
7047d5af
mentioned in commit 7047d5afb761e5bcf903e41446a96de38c95ce9a
Toggle commit list
Yassine Doghri
@yassine
mentioned in commit
d807ab97
·
Jan 23, 2022
mentioned in commit
d807ab97
mentioned in commit d807ab9732d8e4ad1eae956f0b728f8a5c0f868d
Toggle commit list
Write
Preview
Supports
Markdown
0%