Add Direct Messages

This commit is contained in:
Daniel Supernault 2020-11-18 14:19:02 -07:00
parent 4d04e4fd25
commit d63569c120
No known key found for this signature in database
GPG Key ID: 0DEF1C662C9033F7
24 changed files with 1938 additions and 79 deletions

View File

@ -14,7 +14,7 @@ class DirectMessage extends Model
public function url()
{
return url('/i/message/' . $this->to_id . '/' . $this->id);
return config('app.url') . '/account/direct/m/' . $this->status_id;
}
public function author()
@ -22,8 +22,29 @@ class DirectMessage extends Model
return $this->hasOne(Profile::class, 'id', 'from_id');
}
public function recipient()
{
return $this->hasOne(Profile::class, 'id', 'to_id');
}
public function me()
{
return Auth::user()->profile->id === $this->from_id;
}
public function toText()
{
$actorName = $this->author->username;
return "{$actorName} sent a direct message.";
}
public function toHtml()
{
$actorName = $this->author->username;
$actorUrl = $this->author->url();
$url = $this->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> sent a <a href='{$url}' class='dm-link'>direct message</a>.";
}
}

View File

@ -13,6 +13,7 @@ use Illuminate\Http\Request;
use PragmaRX\Google2FA\Google2FA;
use App\Jobs\FollowPipeline\FollowPipeline;
use App\{
DirectMessage,
EmailVerification,
Follower,
FollowRequest,
@ -114,19 +115,17 @@ class AccountController extends Controller
}
}
public function messages()
{
return view('account.messages');
}
public function direct()
{
return view('account.direct');
}
public function showMessage(Request $request, $id)
public function directMessage(Request $request, $id)
{
return view('account.message');
$profile = Profile::where('id', '!=', $request->user()->profile_id)
// ->whereNull('domain')
->findOrFail($id);
return view('account.directmessage', compact('id'));
}
public function mute(Request $request)

View File

@ -2,57 +2,616 @@
namespace App\Http\Controllers;
use Auth;
use Auth, Cache;
use Illuminate\Http\Request;
use App\{
DirectMessage,
Media,
Notification,
Profile,
Status
Status,
User,
UserFilter,
UserSetting
};
use App\Services\MediaPathService;
use App\Services\MediaBlocklistService;
use App\Jobs\StatusPipeline\NewStatusPipeline;
use Illuminate\Support\Str;
use App\Util\ActivityPub\Helpers;
class DirectMessageController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function __construct()
{
$this->middleware('auth');
}
public function inbox(Request $request)
{
$profile = Auth::user()->profile;
$inbox = DirectMessage::selectRaw('*, max(created_at) as createdAt')
->whereToId($profile->id)
->with(['author','status'])
->orderBy('createdAt', 'desc')
->groupBy('from_id')
->paginate(12);
return view('account.messages', compact('inbox'));
public function browse(Request $request)
{
$this->validate($request, [
'a' => 'nullable|string|in:inbox,sent,filtered',
'page' => 'nullable|integer|min:1|max:99'
]);
}
$profile = $request->user()->profile_id;
$action = $request->input('a', 'inbox');
$page = $request->input('page');
public function show(Request $request, int $pid, $mid)
{
$profile = Auth::user()->profile;
if($action == 'inbox') {
$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
->whereToId($profile)
->with(['author','status'])
->whereIsHidden(false)
->groupBy('from_id')
->latest()
->when($page, function($q, $page) {
if($page > 1) {
return $q->offset($page * 8 - 8);
}
})
->limit(8)
->get()
->map(function($r) use($profile) {
return $r->from_id !== $profile ? [
'id' => (string) $r->from_id,
'name' => $r->author->name,
'username' => $r->author->username,
'avatar' => $r->author->avatarUrl(),
'url' => $r->author->url(),
'isLocal' => (bool) !$r->author->domain,
'domain' => $r->author->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
] : [
'id' => (string) $r->to_id,
'name' => $r->recipient->name,
'username' => $r->recipient->username,
'avatar' => $r->recipient->avatarUrl(),
'url' => $r->recipient->url(),
'isLocal' => (bool) !$r->recipient->domain,
'domain' => $r->recipient->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
];
});
}
if($pid !== $profile->id) {
abort(403);
}
if($action == 'sent') {
$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
->whereFromId($profile)
->with(['author','status'])
->groupBy('to_id')
->orderBy('createdAt', 'desc')
->when($page, function($q, $page) {
if($page > 1) {
return $q->offset($page * 8 - 8);
}
})
->limit(8)
->get()
->map(function($r) use($profile) {
return $r->from_id !== $profile ? [
'id' => (string) $r->from_id,
'name' => $r->author->name,
'username' => $r->author->username,
'avatar' => $r->author->avatarUrl(),
'url' => $r->author->url(),
'isLocal' => (bool) !$r->author->domain,
'domain' => $r->author->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
] : [
'id' => (string) $r->to_id,
'name' => $r->recipient->name,
'username' => $r->recipient->username,
'avatar' => $r->recipient->avatarUrl(),
'url' => $r->recipient->url(),
'isLocal' => (bool) !$r->recipient->domain,
'domain' => $r->recipient->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
];
});
}
$msg = DirectMessage::whereToId($profile->id)
->findOrFail($mid);
if($action == 'filtered') {
$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
->whereToId($profile)
->with(['author','status'])
->whereIsHidden(true)
->groupBy('from_id')
->orderBy('createdAt', 'desc')
->when($page, function($q, $page) {
if($page > 1) {
return $q->offset($page * 8 - 8);
}
})
->limit(8)
->get()
->map(function($r) use($profile) {
return $r->from_id !== $profile ? [
'id' => (string) $r->from_id,
'name' => $r->author->name,
'username' => $r->author->username,
'avatar' => $r->author->avatarUrl(),
'url' => $r->author->url(),
'isLocal' => (bool) !$r->author->domain,
'domain' => $r->author->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
] : [
'id' => (string) $r->to_id,
'name' => $r->recipient->name,
'username' => $r->recipient->username,
'avatar' => $r->recipient->avatarUrl(),
'url' => $r->recipient->url(),
'isLocal' => (bool) !$r->recipient->domain,
'domain' => $r->recipient->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => $r->status->caption,
'messages' => []
];
});
}
$thread = DirectMessage::whereIn('to_id', [$profile->id, $msg->from_id])
->whereIn('from_id', [$profile->id,$msg->from_id])
->orderBy('created_at', 'desc')
->paginate(30);
return response()->json($dms);
}
$thread = $thread->reverse();
public function create(Request $request)
{
$this->validate($request, [
'to_id' => 'required',
'message' => 'required|string|min:1|max:500',
'type' => 'required|in:text,emoji'
]);
return view('account.message', compact('msg', 'profile', 'thread'));
}
$profile = $request->user()->profile;
$recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
public function compose(Request $request)
{
$profile = Auth::user()->profile;
}
abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
$msg = $request->input('message');
if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
if($recipient->follows($profile) == true) {
$hidden = false;
} else {
$hidden = true;
}
} else {
$hidden = false;
}
$status = new Status;
$status->profile_id = $profile->id;
$status->caption = $msg;
$status->rendered = $msg;
$status->visibility = 'direct';
$status->scope = 'direct';
$status->in_reply_to_profile_id = $recipient->id;
$status->save();
$dm = new DirectMessage;
$dm->to_id = $recipient->id;
$dm->from_id = $profile->id;
$dm->status_id = $status->id;
$dm->is_hidden = $hidden;
$dm->type = $request->input('type');
$dm->save();
if(filter_var($msg, FILTER_VALIDATE_URL)) {
if(Helpers::validateUrl($msg)) {
$dm->type = 'link';
$dm->meta = [
'domain' => parse_url($msg, PHP_URL_HOST),
'local' => parse_url($msg, PHP_URL_HOST) ==
parse_url(config('app.url'), PHP_URL_HOST)
];
$dm->save();
}
}
$nf = UserFilter::whereUserId($recipient->id)
->whereFilterableId($profile->id)
->whereFilterableType('App\Profile')
->whereFilterType('dm.mute')
->exists();
if($recipient->domain == null && $hidden == false && !$nf) {
$notification = new Notification();
$notification->profile_id = $recipient->id;
$notification->actor_id = $profile->id;
$notification->action = 'dm';
$notification->message = $dm->toText();
$notification->rendered = $dm->toHtml();
$notification->item_id = $dm->id;
$notification->item_type = "App\DirectMessage";
$notification->save();
}
if($recipient->domain) {
$this->remoteDeliver($dm);
}
$res = [
'id' => (string) $dm->id,
'isAuthor' => $profile->id == $dm->from_id,
'hidden' => (bool) $dm->is_hidden,
'type' => $dm->type,
'text' => $dm->status->caption,
'media' => null,
'timeAgo' => $dm->created_at->diffForHumans(null,null,true),
'seen' => $dm->read_at != null,
'meta' => $dm->meta
];
return response()->json($res);
}
public function thread(Request $request)
{
$this->validate($request, [
'pid' => 'required'
]);
$uid = $request->user()->profile_id;
$pid = $request->input('pid');
$max_id = $request->input('max_id');
$min_id = $request->input('min_id');
$r = Profile::findOrFail($pid);
// $r = Profile::whereNull('domain')->findOrFail($pid);
if($min_id) {
$res = DirectMessage::select('*')
->where('id', '>', $min_id)
->where(function($q) use($pid,$uid) {
return $q->where([['from_id',$pid],['to_id',$uid]
])->orWhere([['from_id',$uid],['to_id',$pid]]);
})
->latest()
->take(8)
->get();
} else if ($max_id) {
$res = DirectMessage::select('*')
->where('id', '<', $max_id)
->where(function($q) use($pid,$uid) {
return $q->where([['from_id',$pid],['to_id',$uid]
])->orWhere([['from_id',$uid],['to_id',$pid]]);
})
->latest()
->take(8)
->get();
} else {
$res = DirectMessage::where(function($q) use($pid,$uid) {
return $q->where([['from_id',$pid],['to_id',$uid]
])->orWhere([['from_id',$uid],['to_id',$pid]]);
})
->latest()
->take(8)
->get();
}
$res = $res->map(function($s) use ($uid){
return [
'id' => (string) $s->id,
'hidden' => (bool) $s->is_hidden,
'isAuthor' => $uid == $s->from_id,
'type' => $s->type,
'text' => $s->status->caption,
'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null,
'timeAgo' => $s->created_at->diffForHumans(null,null,true),
'seen' => $s->read_at != null,
'meta' => json_decode($s->meta,true)
];
});
$w = [
'id' => (string) $r->id,
'name' => $r->name,
'username' => $r->username,
'avatar' => $r->avatarUrl(),
'url' => $r->url(),
'muted' => UserFilter::whereUserId($uid)
->whereFilterableId($r->id)
->whereFilterableType('App\Profile')
->whereFilterType('dm.mute')
->first() ? true : false,
'isLocal' => (bool) !$r->domain,
'domain' => $r->domain,
'timeAgo' => $r->created_at->diffForHumans(null, true, true),
'lastMessage' => '',
'messages' => $res
];
return response()->json($w, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function delete(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$sid = $request->input('id');
$pid = $request->user()->profile_id;
$dm = DirectMessage::whereFromId($pid)
->findOrFail($sid);
$status = Status::whereProfileId($pid)
->findOrFail($dm->status_id);
if($dm->recipient->domain) {
$dmc = $dm;
$this->remoteDelete($dmc);
}
$status->delete();
$dm->delete();
return [200];
}
public function get(Request $request, $id)
{
$pid = $request->user()->profile_id;
$dm = DirectMessage::whereStatusId($id)->firstOrFail();
abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404);
return response()->json($dm, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function mediaUpload(Request $request)
{
$this->validate($request, [
'file' => function() {
return [
'required',
'mimes:' . config('pixelfed.media_types'),
'max:' . config('pixelfed.max_photo_size'),
];
},
'to_id' => 'required'
]);
$user = $request->user();
$profile = $user->profile;
$recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
if($recipient->follows($profile) == true) {
$hidden = false;
} else {
$hidden = true;
}
} else {
$hidden = false;
}
if(config('pixelfed.enforce_account_limit') == true) {
$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
return Media::whereUserId($user->id)->sum('size') / 1000;
});
$limit = (int) config('pixelfed.max_account_size');
if ($size >= $limit) {
abort(403, 'Account size limit reached.');
}
}
$photo = $request->file('file');
$mimes = explode(',', config('pixelfed.media_types'));
if(in_array($photo->getMimeType(), $mimes) == false) {
abort(403, 'Invalid or unsupported mime type.');
}
$storagePath = MediaPathService::get($user, 2) . Str::random(8);
$path = $photo->store($storagePath);
$hash = \hash_file('sha256', $photo);
abort_if(MediaBlocklistService::exists($hash) == true, 451);
$status = new Status;
$status->profile_id = $profile->id;
$status->caption = null;
$status->rendered = null;
$status->visibility = 'direct';
$status->scope = 'direct';
$status->in_reply_to_profile_id = $recipient->id;
$status->save();
$media = new Media();
$media->status_id = $status->id;
$media->profile_id = $profile->id;
$media->user_id = $user->id;
$media->media_path = $path;
$media->original_sha256 = $hash;
$media->size = $photo->getSize();
$media->mime = $photo->getMimeType();
$media->caption = null;
$media->filter_class = null;
$media->filter_name = null;
$media->save();
$dm = new DirectMessage;
$dm->to_id = $recipient->id;
$dm->from_id = $profile->id;
$dm->status_id = $status->id;
$dm->type = array_first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo';
$dm->is_hidden = $hidden;
$dm->save();
if($recipient->domain) {
$this->remoteDeliver($dm);
}
return [
'type' => $dm->type,
'url' => $media->url()
];
}
public function composeLookup(Request $request)
{
$this->validate($request, [
'username' => 'required'
]);
$username = $request->input('username');
$profile = Profile::whereUsername($username)->firstOrFail();
return ['id' => (string)$profile->id];
}
public function read(Request $request)
{
$this->validate($request, [
'pid' => 'required',
'sid' => 'required'
]);
$pid = $request->input('pid');
$sid = $request->input('sid');
$dms = DirectMessage::whereToId($request->user()->profile_id)
->whereFromId($pid)
->where('status_id', '>=', $sid)
->get();
$now = now();
foreach($dms as $dm) {
$dm->read_at = $now;
$dm->save();
}
return response()->json($dms->pluck('id'));
}
public function mute(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$fid = $request->input('id');
$pid = $request->user()->profile_id;
UserFilter::firstOrCreate(
[
'user_id' => $pid,
'filterable_id' => $fid,
'filterable_type' => 'App\Profile',
'filter_type' => 'dm.mute'
]
);
return [200];
}
public function unmute(Request $request)
{
$this->validate($request, [
'id' => 'required'
]);
$fid = $request->input('id');
$pid = $request->user()->profile_id;
$f = UserFilter::whereUserId($pid)
->whereFilterableId($fid)
->whereFilterableType('App\Profile')
->whereFilterType('dm.mute')
->firstOrFail();
$f->delete();
return [200];
}
public function remoteDeliver($dm)
{
$profile = $dm->author;
$url = $dm->recipient->inbox_url;
$tags = [
[
'type' => 'Mention',
'href' => $dm->recipient->permalink(),
'name' => $dm->recipient->emailUrl(),
]
];
$body = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'sc' => 'http://schema.org#',
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
]
],
'id' => $dm->status->permalink(),
'type' => 'Create',
'actor' => $dm->status->profile->permalink(),
'published' => $dm->status->created_at->toAtomString(),
'to' => [$dm->recipient->permalink()],
'cc' => [],
'object' => [
'id' => $dm->status->url(),
'type' => 'Note',
'summary' => null,
'content' => $dm->status->rendered ?? $dm->status->caption,
'inReplyTo' => null,
'published' => $dm->status->created_at->toAtomString(),
'url' => $dm->status->url(),
'attributedTo' => $dm->status->profile->permalink(),
'to' => [$dm->recipient->permalink()],
'cc' => [],
'sensitive' => (bool) $dm->status->is_nsfw,
'attachment' => $dm->status->media()->orderBy('order')->get()->map(function ($media) {
return [
'type' => $media->activityVerb(),
'mediaType' => $media->mime,
'url' => $media->url(),
'name' => $media->caption,
];
})->toArray(),
'tag' => $tags,
]
];
Helpers::sendSignedObject($profile, $url, $body);
}
public function remoteDelete($dm)
{
$profile = $dm->author;
$url = $dm->recipient->inbox_url;
$body = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'sc' => 'http://schema.org#',
'Hashtag' => 'as:Hashtag',
'sensitive' => 'as:sensitive',
]
],
'id' => $dm->status->permalink('#delete'),
'to' => [
'https://www.w3.org/ns/activitystreams#Public'
],
'type' => 'Delete',
'actor' => $dm->status->profile->permalink(),
'object' => [
'id' => $dm->status->url(),
'type' => 'Tombstone'
]
];
Helpers::sendSignedObject($profile, $url, $body);
}
}

View File

@ -34,7 +34,8 @@ class FederationController extends Controller
public function nodeinfoWellKnown()
{
abort_if(!config('federation.nodeinfo.enabled'), 404);
return response()->json(Nodeinfo::wellKnown());
return response()->json(Nodeinfo::wellKnown())
->header('Access-Control-Allow-Origin','*');
}
public function nodeinfo()
@ -62,7 +63,8 @@ class FederationController extends Controller
}
$webfinger = (new Webfinger($profile))->generate();
return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT);
return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT)
->header('Access-Control-Allow-Origin','*');
}
public function hostMeta(Request $request)

View File

@ -34,6 +34,7 @@ trait PrivacySettings
$fields = [
'is_private',
'crawlable',
'public_dm',
'show_profile_follower_count',
'show_profile_following_count',
];
@ -56,6 +57,12 @@ trait PrivacySettings
} else {
$settings->{$field} = true;
}
} elseif ($field == 'public_dm') {
if ($form == 'on') {
$settings->{$field} = true;
} else {
$settings->{$field} = false;
}
} else {
if ($form == 'on') {
$settings->{$field} = true;

View File

@ -50,6 +50,7 @@ class NotificationTransformer extends Fractal\TransformerAbstract
public function replaceTypeVerb($verb)
{
$verbs = [
'dm' => 'direct',
'follow' => 'follow',
'mention' => 'mention',
'reblog' => 'share',

View File

@ -19,6 +19,7 @@ use App\Jobs\LikePipeline\LikePipeline;
use App\Jobs\FollowPipeline\FollowPipeline;
use App\Util\ActivityPub\Validator\Accept as AcceptValidator;
use App\Util\ActivityPub\Validator\Add as AddValidator;
use App\Util\ActivityPub\Validator\Announce as AnnounceValidator;
use App\Util\ActivityPub\Validator\Follow as FollowValidator;
use App\Util\ActivityPub\Validator\Like as LikeValidator;
@ -57,6 +58,12 @@ class Inbox
{
$verb = (string) $this->payload['type'];
switch ($verb) {
case 'Add':
if(AddValidator::validate($this->payload) == false) { return; }
$this->handleAddActivity();
break;
case 'Create':
$this->handleCreateActivity();
break;
@ -121,6 +128,11 @@ class Inbox
return Helpers::profileFetch($actorUrl);
}
public function handleAddActivity()
{
// stories ;)
}
public function handleCreateActivity()
{
$activity = $this->payload['object'];
@ -157,6 +169,15 @@ class Inbox
if(!$actor || $actor->domain == null) {
return;
}
$to = $activity['to'];
$cc = $activity['cc'];
if(count($to) == 1 &&
count($cc) == 0 &&
parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
) {
$this->handleDirectMessage();
return;
}
if($actor->followers()->count() == 0) {
return;
@ -170,6 +191,103 @@ class Inbox
return;
}
public function handleDirectMessage()
{
$activity = $this->payload['object'];
$actor = $this->actorFirstOrCreate($this->payload['actor']);
$profile = Profile::whereNull('domain')
->whereUsername(array_last(explode('/', $activity['to'][0])))
->firstOrFail();
if(in_array($actor->id, $profile->blockedIds()->toArray())) {
return;
}
$msg = $activity['content'];
$msgText = strip_tags($activity['content']);
if($profile->user->settings->public_dm == false || $profile->is_private) {
if($profile->follows($actor) == true) {
$hidden = false;
} else {
$hidden = true;
}
} else {
$hidden = false;
}
$status = new Status;
$status->profile_id = $actor->id;
$status->caption = $msgText;
$status->rendered = $msg;
$status->visibility = 'direct';
$status->scope = 'direct';
$status->in_reply_to_profile_id = $profile->id;
$status->save();
$dm = new DirectMessage;
$dm->to_id = $profile->id;
$dm->from_id = $actor->id;
$dm->status_id = $status->id;
$dm->is_hidden = $hidden;
$dm->type = $request->input('type');
$dm->save();
if(count($activity['attachment'])) {
$allowed = explode(',', config('pixelfed.media_types'));
foreach($activity['attachment'] as $a) {
$type = $a['mediaType'];
$url = $a['url'];
$valid = self::validateUrl($url);
if(in_array($type, $allowed) == false || $valid == false) {
continue;
}
$media = new Media();
$media->remote_media = true;
$media->status_id = $status->id;
$media->profile_id = $status->profile_id;
$media->user_id = null;
$media->media_path = $url;
$media->remote_url = $url;
$media->mime = $type;
$media->save();
}
}
if(filter_var($msg, FILTER_VALIDATE_URL)) {
if(Helpers::validateUrl($msg)) {
$dm->type = 'link';
$dm->meta = [
'domain' => parse_url($msg, PHP_URL_HOST),
'local' => parse_url($msg, PHP_URL_HOST) ==
parse_url(config('app.url'), PHP_URL_HOST)
];
$dm->save();
}
}
$nf = UserFilter::whereUserId($profile->id)
->whereFilterableId($actor->id)
->whereFilterableType('App\Profile')
->whereFilterType('dm.mute')
->exists();
if($profile->domain == null && $hidden == false && !$nf) {
$notification = new Notification();
$notification->profile_id = $profile->id;
$notification->actor_id = $actor->id;
$notification->action = 'dm';
$notification->message = $dm->toText();
$notification->rendered = $dm->toHtml();
$notification->item_id = $dm->id;
$notification->item_type = "App\DirectMessage";
$notification->save();
}
return;
}
public function handleFollowActivity()
{
$actor = $this->actorFirstOrCreate($this->payload['actor']);
@ -305,7 +423,20 @@ class Inbox
}
$actor = $this->payload['actor'];
$obj = $this->payload['object'];
if(is_string($obj) == true) {
if(is_string($obj) == true && $actor == $obj && Helpers::validateUrl($obj)) {
$profile = Profile::whereRemoteUrl($obj)->first();
if(!$profile || $profile->private_key != null) {
return;
}
Notification::whereActorId($profile->id)->delete();
$profile->avatar()->delete();
$profile->followers()->delete();
$profile->following()->delete();
$profile->likes()->delete();
$profile->media()->delete();
$profile->hashtags()->delete();
$profile->statuses()->delete();
$profile->delete();
return;
}
$type = $this->payload['object']['type'];
@ -319,9 +450,7 @@ class Inbox
$id = $this->payload['object']['id'];
switch ($type) {
case 'Person':
// todo: fix race condition
return;
$profile = Helpers::profileFetch($actor);
$profile = Profile::whereRemoteUrl($actor)->first();
if(!$profile || $profile->private_key != null) {
return;
}
@ -331,6 +460,7 @@ class Inbox
$profile->following()->delete();
$profile->likes()->delete();
$profile->media()->delete();
$profile->hashtags()->delete();
$profile->statuses()->delete();
$profile->delete();
return;

View File

@ -0,0 +1,41 @@
<?php
namespace App\Util\ActivityPub\Validator;
use Validator;
use Illuminate\Validation\Rule;
class Add {
public static function validate($payload)
{
$valid = Validator::make($payload, [
'@context' => 'required',
'id' => 'required|string',
'type' => [
'required',
Rule::in(['Add'])
],
'actor' => 'required|url',
'object' => 'required',
'object.id' => 'required|url',
'object.type' => [
'required',
Rule::in(['Story'])
],
'object.attributedTo' => 'required|url|same:actor',
'object.attachment' => 'required',
'object.attachment.type' => [
'required',
Rule::in(['Image'])
],
'object.attachment.url' => 'required|url',
'object.attachment.mediaType' => [
'required',
Rule::in(['image/jpeg', 'image/png'])
]
])->passes();
return $valid;
}
}

View File

@ -4,19 +4,23 @@ namespace App\Util\Lexer;
class PrettyNumber
{
public static function convert($expression)
public static function convert($number)
{
if(!is_integer($number)) {
return $number;
}
$abbrevs = [12 => 'T', 9 => 'B', 6 => 'M', 3 => 'K', 0 => ''];
foreach ($abbrevs as $exponent => $abbrev) {
if ($expression >= pow(10, $exponent)) {
$display_num = $expression / pow(10, $exponent);
$num = number_format($display_num, 0).$abbrev;
return $num;
if(abs($number) >= pow(10, $exponent)) {
$display = $number / pow(10, $exponent);
$decimals = ($exponent >= 3 && round($display) < 100) ? 1 : 0;
$number = number_format($display, $decimals).$abbrev;
break;
}
}
return $expression;
return $number;
}
public static function size($expression, $kb = false)

View File

@ -165,6 +165,8 @@ class Image
$quality = config('pixelfed.image_quality');
$img->save($newPath, $quality);
$media->width = $img->width();
$media->height = $img->height();
$img->destroy();
if (!$thumbnail) {
$media->orientation = $orientation;
@ -178,6 +180,7 @@ class Image
$media->mime = $img->mime;
}
$media->save();
Cache::forget('status:transformer:media:attachments:'.$media->status_id);
Cache::forget('status:thumb:'.$media->status_id);

View File

@ -21,7 +21,9 @@ return [
* You can enable CORS for 1 or multiple paths.
* Example: ['api/*']
*/
'paths' => [],
'paths' => [
'.well-known/*'
],
/*
* Matches the request method. `[*]` allows all methods.

View File

@ -52,6 +52,11 @@
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> tagged you in a <a class="font-weight-bold" v-bind:href="n.tagged.post_url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'direct'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> sent a <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">dm</a>.
</p>
</div>
<div class="align-items-center">
<span class="small text-muted" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</span>
</div>
@ -249,6 +254,9 @@ export default {
case 'tagged':
return n.tagged.post_url;
break;
case 'direct':
return '/account/direct/t/'+n.account.id;
break
}
return '/';
},

View File

@ -237,7 +237,7 @@
<div class="media-body">
<div class="form-group">
<label class="font-weight-bold text-muted small d-none">Caption</label>
<textarea class="form-control border-0 rounded-0 no-focus" rows="2" placeholder="Write a caption..." style="resize:none" v-model="composeText" v-on:keyup="composeTextLength = composeText.length"></textarea>
<textarea class="form-control border-0 rounded-0 no-focus" rows="3" placeholder="Write a caption..." style="" v-model="composeText" v-on:keyup="composeTextLength = composeText.length"></textarea>
<p class="help-text small text-right text-muted mb-0">{{composeTextLength}}/{{config.uploader.max_caption_length}}</p>
</div>
</div>
@ -271,7 +271,7 @@
<p class="px-4 mb-0 py-2">
<span>Audience</span>
<span class="float-right">
<a v-if="profile.locked == false" href="#" @click.prevent="showVisibilityCard()" class="btn btn-outline-secondary btn-sm small mr-3 mt-n1 disabled" style="font-size:10px;padding:3px;text-transform: uppercase" disabled>{{visibilityTag}}</a>
<a href="#" @click.prevent="showVisibilityCard()" class="btn btn-outline-secondary btn-sm small mr-3 mt-n1 disabled" style="font-size:10px;padding:3px;text-transform: uppercase" disabled>{{visibilityTag}}</a>
<a href="#" @click.prevent="showVisibilityCard()" class="text-decoration-none"><i class="fas fa-chevron-right fa-lg text-lighter"></i></a>
</span>
</p>
@ -632,12 +632,13 @@ export default {
methods: {
fetchProfile() {
let self = this;
axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
this.profile = res.data;
self.profile = res.data;
window.pixelfed.currentUser = res.data;
if(res.data.locked == true) {
this.visibility = 'private';
this.visibilityTag = 'Followers Only';
self.visibility = 'private';
self.visibilityTag = 'Followers Only';
}
}).catch(err => {
});
@ -663,6 +664,9 @@ export default {
let self = this;
self.uploading = true;
let io = document.querySelector('#pf-dz');
if(!io.files.length) {
self.uploading = false;
}
Array.prototype.forEach.call(io.files, function(io, i) {
if(self.media && self.media.length + i >= self.config.uploader.album_limit) {
swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error');

View File

@ -0,0 +1,400 @@
<template>
<div>
<div v-if="loaded && page == 'browse'" class="container messages-page p-0 p-md-2 mt-n4" style="min-height: 50vh;">
<div class="col-12 col-md-8 offset-md-2 p-0 px-md-2">
<div class="card shadow-none border mt-4">
<div class="card-header bg-white py-4">
<span class="h4 font-weight-bold mb-0">Direct Messages</span>
<span class="float-right">
<a class="btn btn-outline-primary font-weight-bold py-0" href="#" @click.prevent="goto('add')">New Message</a>
</span>
</div>
<div class="card-header bg-white">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<a :class="[tab == 'inbox' ? 'nav-link px-4 font-weight-bold rounded-pill active' : 'nav-link px-4 font-weight-bold rounded-pill']" @click.prevent="switchTab('inbox')" href="#">Inbox</a>
</li>
<li class="nav-item">
<a :class="[tab == 'sent' ? 'nav-link px-4 font-weight-bold rounded-pill active' : 'nav-link px-4 font-weight-bold rounded-pill']" @click.prevent="switchTab('sent')" href="#">Sent</a>
</li>
<li class="nav-item">
<a :class="[tab == 'filtered' ? 'nav-link px-4 font-weight-bold rounded-pill active' : 'nav-link px-4 font-weight-bold rounded-pill']" @click.prevent="switchTab('filtered')" href="#">Filtered</a>
</li>
</ul>
</div>
<ul v-if="tab == 'inbox'" class="list-group list-group-flush">
<div v-if="!messages.inbox.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
<p class="lead mb-0">No messages found :(</p>
</div>
<div v-else v-for="(thread, index) in messages.inbox">
<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" :href="'/account/direct/t/'+thread.id">
<div class="media d-flex align-items-center">
<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
<div class="media-body">
<p class="mb-0">
<span class="font-weight-bold text-truncate" :title="[thread.isLocal ? '@' + thread.username : thread.username]" data-toggle="tooltip" data-placement="bottom">
{{thread.name}}
</span>
</p>
<p class="text-muted mb-0" style="font-size:13px;font-weight: 500;">
<span>
<i class="far fa-comment text-primary"></i>
</span>
<span class="pl-1 pr-3">
Received
</span>
<span>
{{thread.timeAgo}}
</span>
</p>
</div>
<span class="float-right">
<i class="fas fa-chevron-right fa-lg text-lighter"></i>
</span>
</div>
</a>
</div>
</ul>
<ul v-if="tab == 'sent'" class="list-group list-group-flush">
<div v-if="!messages.sent.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
<p class="lead mb-0">No messages found :(</p>
</div>
<div v-else v-for="(thread, index) in messages.sent">
<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" href="#" @click.prevent="loadMessage(thread.id)">
<div class="media d-flex align-items-center">
<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
<div class="media-body">
<p class="mb-0">
<span class="font-weight-bold text-truncate" :title="[thread.isLocal ? '@' + thread.username : thread.username]" data-toggle="tooltip" data-placement="bottom">
{{thread.name}}
</span>
</p>
<p class="text-muted mb-0" style="font-size:13px;font-weight: 500;">
<span>
<i class="far fa-paper-plane text-primary"></i>
</span>
<span class="pl-1 pr-3">
Delivered
</span>
<span>
{{thread.timeAgo}}
</span>
</p>
</div>
<span class="float-right">
<i class="fas fa-chevron-right fa-lg text-lighter"></i>
</span>
</div>
</a>
</div>
</ul>
<ul v-if="tab == 'filtered'" class="list-group list-group-flush">
<div v-if="!messages.filtered.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
<p class="lead mb-0">No messages found :(</p>
</div>
<div v-else v-for="(thread, index) in messages.filtered">
<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" href="#" @click.prevent="loadMessage(thread.id)">
<div class="media d-flex align-items-center">
<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
<div class="media-body">
<p class="mb-0">
<span class="font-weight-bold text-truncate" :title="[thread.isLocal ? '@' + thread.username : thread.username]" data-toggle="tooltip" data-placement="bottom">
{{thread.name}}
</span>
</p>
<p class="text-muted mb-0" style="font-size:13px;font-weight: 500;">
<span>
<i class="fas fa-shield-alt" style="color:#fd9426"></i>
</span>
<span class="pl-1 pr-3">
Filtered
</span>
<span>
{{thread.timeAgo}}
</span>
</p>
</div>
<span class="float-right">
<i class="fas fa-chevron-right fa-lg text-lighter"></i>
</span>
</div>
</a>
</div>
</ul>
</div>
<div v-if="tab == 'inbox'" class="mt-3 text-center">
<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="inboxPage == 1" @click="messagePagination('inbox', 'prev')">Prev</button>
<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="messages.inbox.length != 8" @click="messagePagination('inbox', 'next')">Next</button>
</div>
<div v-if="tab == 'sent'" class="mt-3 text-center">
<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="sentPage == 1" @click="messagePagination('sent', 'prev')">Prev</button>
<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="messages.sent.length != 8" @click="messagePagination('sent', 'next')">Next</button>
</div>
<div v-if="tab == 'filtered'" class="mt-3 text-center">
<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="filteredPage == 1" @click="messagePagination('filtered', 'prev')">Prev</button>
<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="messages.filtered.length != 8" @click="messagePagination('filtered', 'next')">Next</button>
</div>
</div>
</div>
<div v-if="loaded && page == 'add'" class="container messages-page p-0 p-md-2 mt-n4" style="min-height: 60vh;">
<div class="col-12 col-md-8 offset-md-2 p-0 px-md-2">
<div class="card shadow-none border mt-4">
<div class="card-header bg-white py-4 d-flex justify-content-between">
<span class="cursor-pointer px-3" @click="goto('browse')"><i class="fas fa-chevron-left"></i></span>
<span class="h4 font-weight-bold mb-0">New Direct Message</span>
<span><i class="fas fa-chevron-right text-white"></i></span>
</div>
<div class="card-body d-flex align-items-center justify-content-center" style="height: 60vh;">
<div class="">
<p class="form-group">
<label>To:</label>
<!-- <div class="input-group pt-0">
<div class="input-group-prepend">
<span class="input-group-text" id="basic-addon1">@</span>
</div>
<input v-model="composeUsername" type="text" class="form-control" placeholder="dansup">
</div> -->
<autocomplete
v-show="true"
:search="composeSearch"
placeholder="@dansup"
aria-label="Search usernames"
:get-result-value="getTagResultValue"
@submit="onTagSubmitLocation"
ref="autocomplete"
>
</autocomplete>
<span class="help-text small text-muted">Select a username to send a message to.</span>
</p>
<hr>
<!-- <p>
<button type="button" class="btn btn-primary font-weight-bold btn-block" @click="composeUsernameSelect()" :disabled="!composeUsername.length">Next</button>
</p> -->
<ul class="text-muted">
<li>You cannot message remote accounts yet.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style type="text/css" scoped>
</style>
<script type="text/javascript">
export default {
data() {
return {
config: window.App.config,
loaded: false,
profile: {},
page: 'browse',
pages: ['browse', 'add', 'read'],
tab: 'inbox',
tabs: ['inbox', 'sent', 'filtered'],
inboxPage: 1,
sentPage: 1,
filteredPage: 1,
threads: [],
thread: false,
threadIndex: false,
replyText: '',
composeUsername: '',
ctxContext: null,
ctxIndex: null,
uploading: false,
uploadProgress: null,
messages: {
inbox: [],
sent: [],
filtered: []
}
}
},
mounted() {
this.fetchProfile();
let self = this;
axios.get('/api/pixelfed/v1/direct/browse', {
params: {
a: 'inbox'
}
})
.then(res => {
self.loaded = true;
this.threads = res.data
this.messages.inbox = res.data;
});
},
updated() {
$('[data-toggle="tooltip"]').tooltip();
},
methods: {
fetchProfile() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
this.profile = res.data;
window._sharedData.curUser = res.data;
});
},
goto(l = 'browse') {
this.page = l;
let url = '/account/direct';
switch(l) {
case 'read':
url = '/account/direct/t/' + this.thread.id;
break;
case 'add':
url += '#/new';
break;
}
window.history.pushState({},'',url);
},
loadMessage(id) {
let url = '/account/direct/t/' + id;
window.location.href = url;
return;
},
composeUsernameSelect() {
if(this.profile.username == this.composeUsername) {
swal('Ooops!', 'You cannot send a direct message to yourself.', 'error');
this.composeUsername = '';
return;
}
axios.post('/api/direct/lookup', {
username: this.composeUsername
}).then(res => {
let url = '/account/direct/t/' + res.data.id;
window.location.href = url;
}).catch(err => {
let msg = 'The username you entered is incorrect. Please try again';
swal('Ooops!', msg, 'error');
this.composeUsername = '';
});
},
truncate(t) {
return _.truncate(t);
},
switchTab(tab) {
let self = this;
switch(tab) {
case 'inbox':
if(this.messages.inbox.length == 0) {
// fetch
}
break;
case 'sent':
if(this.messages.sent.length == 0) {
axios.get('/api/pixelfed/v1/direct/browse', {
params: {
a: 'sent'
}
})
.then(res => {
self.loaded = true;
self.threads = res.data
self.messages.sent = res.data;
});
}
break;
case 'filtered':
if(this.messages.filtered.length == 0) {
axios.get('/api/pixelfed/v1/direct/browse', {
params: {
a: 'filtered'
}
})
.then(res => {
self.loaded = true;
self.threads = res.data
self.messages.filtered = res.data;
});
}
break;
}
this.tab = tab;
},
composeSearch(input) {
if (input.length < 1) { return []; };
let self = this;
let results = [];
return axios.get('/api/local/compose/tag/search', {
params: {
q: input
}
}).then(res => {
return res.data;
});
},
getTagResultValue(result) {
return '@' + result.name;
},
onTagSubmitLocation(result) {
//this.$refs.autocomplete.value = '';
window.location.href = '/account/direct/t/' + result.id;
return;
},
messagePagination(tab, dir) {
if(tab == 'inbox') {
this.inboxPage = dir == 'prev' ? this.inboxPage - 1 : this.inboxPage + 1;
axios.get('/api/pixelfed/v1/direct/browse', {
params: {
a: 'inbox',
page: this.inboxPage
}
})
.then(res => {
self.loaded = true;
this.threads = res.data
this.messages.inbox = res.data;
});
}
if(tab == 'sent') {
this.sentPage = dir == 'prev' ? this.sentPage - 1 : this.sentPage + 1;
axios.get('/api/pixelfed/v1/direct/browse', {
params: {
a: 'sent',
page: this.sentPage
}
})
.then(res => {
self.loaded = true;
this.threads = res.data
this.messages.sent = res.data;
});
}
if(tab == 'filtered') {
this.filteredPage = dir == 'prev' ? this.filteredPage - 1 : this.filteredPage + 1;
axios.get('/api/pixelfed/v1/direct/browse', {
params: {
a: 'filtered',
page: this.filteredPage
}
})
.then(res => {
self.loaded = true;
this.threads = res.data
this.messages.filtered = res.data;
});
}
}
}
}
</script>

View File

@ -0,0 +1,648 @@
<template>
<div>
<div v-if="loaded && page == 'read'" class="container messages-page p-0 p-md-2 mt-n4" style="min-height: 60vh;">
<div class="col-12 col-md-8 offset-md-2 p-0 px-md-2">
<div class="card shadow-none border mt-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<span>
<a href="/account/direct" class="text-muted">
<i class="fas fa-chevron-left fa-lg"></i>
</a>
</span>
<span>
<div class="media">
<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="40px">
<div class="media-body">
<p class="mb-0">
<span class="font-weight-bold">{{thread.name}}</span>
</p>
<p class="mb-0">
<a v-if="!thread.isLocal" :href="'/'+thread.username" class="text-decoration-none text-muted">{{thread.username}}</a>
<a v-else :href="'/'+thread.username" class="text-decoration-none text-muted">&commat;{{thread.username}}</a>
</p>
</div>
</div>
</span>
<span><a href="#" class="text-muted" @click.prevent="showOptions()"><i class="fas fa-cog fa-lg"></i></a></span>
</div>
<ul class="list-group list-group-flush dm-wrapper" style="height:60vh;overflow-y: scroll;">
<li class="list-group-item border-0">
<p class="text-center small text-muted">
Conversation with <span class="font-weight-bold">{{thread.username}}</span>
</p>
<hr>
</li>
<li v-if="showLoadMore && thread.messages && thread.messages.length > 5" class="list-group-item border-0 mt-n4">
<p class="text-center small text-muted">
<button v-if="!loadingMessages" class="btn btn-primary font-weight-bold rounded-pill btn-sm px-3" @click="loadOlderMessages()">Load Older Messages</button>
<button v-else class="btn btn-primary font-weight-bold rounded-pill btn-sm px-3" disabled>Loading...</button>
</p>
</li>
<li v-for="(convo, index) in thread.messages" class="list-group-item border-0 chat-msg cursor-pointer" @click="openCtxMenu(convo, index)">
<div v-if="!convo.isAuthor" class="media d-inline-flex mb-0">
<img v-if="!hideAvatars" class="mr-3 mt-2 rounded-circle img-thumbnail" :src="thread.avatar" alt="avatar" width="32px">
<div class="media-body">
<p v-if="convo.type == 'photo'" class="pill-to p-0 shadow">
<img :src="convo.media" width="140px" style="border-radius:20px;">
</p>
<div v-else-if="convo.type == 'link'" class="media d-inline-flex mb-0 cursor-pointer">
<div class="media-body">
<div class="card mb-2 rounded border shadow" style="width:240px;" :title="convo.text">
<div class="card-body p-0">
<div class="media d-flex align-items-center">
<div v-if="convo.meta.local" class="bg-primary mr-3 border-right p-3">
<i class="fas fa-link text-white fa-2x"></i>
</div>
<div v-else class="bg-light mr-3 border-right p-3">
<i class="fas fa-link text-lighter fa-2x"></i>
</div>
<div class="media-body text-muted small text-truncate pr-2 font-weight-bold">
{{convo.meta.local ? convo.text.substr(8) : convo.meta.domain}}
</div>
</div>
</div>
</div>
</div>
</div>
<p v-else-if="convo.type == 'video'" class="pill-to p-0 shadow">
<!-- <video :src="convo.media" width="140px" style="border-radius:20px;"></video> -->
<span class="d-block bg-primary d-flex align-items-center justify-content-center" style="width:200px;height: 110px;border-radius: 20px;">
<div class="text-center">
<p class="mb-1">
<i class="fas fa-play fa-2x text-white"></i>
</p>
<p class="mb-0 small font-weight-bold text-white">
Play
</p>
</div>
</span>
</p>
<p v-else-if="convo.type == 'emoji'" class="p-0 emoji-msg">
{{convo.text}}
</p>
<p v-else :class="[largerText ? 'pill-to shadow larger-text text-break':'pill-to shadow text-break']">
{{convo.text}}
</p>
<p v-if="!hideTimestamps" class="small text-muted font-weight-bold ml-2 d-flex align-items-center justify-content-start" data-timestamp="timestamp"> <span v-if="convo.hidden" class="mr-2 small" title="Filtered Message" data-toggle="tooltip" data-placement="bottom"><i class="fas fa-lock"></i></span> {{convo.timeAgo}}</p>
</div>
</div>
<div v-else class="media d-inline-flex float-right mb-0">
<div class="media-body">
<p v-if="convo.type == 'photo'" class="pill-from p-0 shadow">
<img :src="convo.media" width="140px" style="border-radius:20px;">
</p>
<div v-else-if="convo.type == 'link'" class="media d-inline-flex float-right mb-0 cursor-pointer">
<div class="media-body">
<div class="card mb-2 rounded border shadow" style="width:240px;" :title="convo.text">
<div class="card-body p-0">
<div class="media d-flex align-items-center">
<div v-if="convo.meta.local" class="bg-primary mr-3 border-right p-3">
<i class="fas fa-link text-white fa-2x"></i>
</div>
<div v-else class="bg-light mr-3 border-right p-3">
<i class="fas fa-link text-lighter fa-2x"></i>
</div>
<div class="media-body text-muted small text-truncate pr-2 font-weight-bold">
{{convo.meta.local ? convo.text.substr(8) : convo.meta.domain}}
</div>
</div>
</div>
</div>
</div>
</div>
<p v-else-if="convo.type == 'video'" class="pill-from p-0 shadow">
<!-- <video :src="convo.media" width="140px" style="border-radius:20px;"></video> -->
<span class="rounded-pill bg-primary d-flex align-items-center justify-content-center" style="width:200px;height: 110px">
<div class="text-center">
<p class="mb-1">
<i class="fas fa-play fa-2x text-white"></i>
</p>
<p class="mb-0 small font-weight-bold">
Play
</p>
</div>
</span>
</p>
<p v-else-if="convo.type == 'emoji'" class="p-0 emoji-msg">
{{convo.text}}
</p>
<p v-else :class="[largerText ? 'pill-from shadow larger-text text-break':'pill-from shadow text-break']">
{{convo.text}}
</p>
<p v-if="!hideTimestamps" class="small text-muted font-weight-bold text-right mr-2"> <span v-if="convo.hidden" class="mr-2 small" title="Filtered Message" data-toggle="tooltip" data-placement="bottom"><i class="fas fa-lock"></i></span> {{convo.timeAgo}}
</p>
</div>
<img v-if="!hideAvatars" class="ml-3 mt-2 rounded-circle img-thumbnail" :src="profile.avatar" alt="avatar" width="32px">
</div>
</li>
</ul>
<div class="card-footer bg-white p-0">
<form class="border-0 rounded-0 align-middle" method="post" action="#">
<textarea class="form-control border-0 rounded-0 no-focus" name="comment" placeholder="Reply ..." autocomplete="off" autocorrect="off" style="height:86px;line-height: 18px;max-height:80px;resize: none; padding-right:115.22px;" v-model="replyText" :disabled="blocked"></textarea>
<input type="button" value="Send" :class="[replyText.length ? 'd-inline-block btn btn-sm btn-primary rounded-pill font-weight-bold reply-btn text-decoration-none text-uppercase' : 'd-inline-block btn btn-sm btn-primary rounded-pill font-weight-bold reply-btn text-decoration-none text-uppercase disabled']" :disabled="replyText.length == 0" @click.prevent="sendMessage"/>
</form>
</div>
<div class="card-footer p-0">
<p class="d-flex justify-content-between align-items-center mb-0 px-3 py-1 small">
<!-- <span class="font-weight-bold" style="color: #D69E2E">
<i class="fas fa-circle mr-1"></i>
Typing ...
</span> -->
<span>
<!-- <span class="btn btn-primary btn-sm font-weight-bold py-0 px-3 rounded-pill" @click="uploadMedia">
<i class="fas fa-share mr-1"></i>
Share
</span> -->
<span class="btn btn-primary btn-sm font-weight-bold py-0 px-3 rounded-pill" @click="uploadMedia">
<i class="fas fa-upload mr-1"></i>
Add Photo/Video
</span>
</span>
<input type="file" id="uploadMedia" class="d-none" name="uploadMedia" accept="image/jpeg,image/png,image/gif,video/mp4" >
<span class="text-muted font-weight-bold">{{replyText.length}}/600</span>
</p>
</div>
</div>
</div>
</div>
<div v-if="loaded && page == 'options'" class="container messages-page p-0 p-md-2 mt-n4" style="min-height: 60vh;">
<div class="col-12 col-md-8 offset-md-2 p-0 px-md-2">
<div class="card shadow-none border mt-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<span>
<a href="#" class="text-muted" @click.prevent="page='read'">
<i class="fas fa-chevron-left fa-lg"></i>
</a>
</span>
<span>
<p class="mb-0 lead font-weight-bold py-2">Message Settings</p>
</span>
<span class="text-lighter" data-toggle="tooltip" data-placement="bottom" title="Have a nice day!"><i class="far fa-smile fa-lg"></i></span>
</div>
<ul class="list-group list-group-flush dm-wrapper" style="height: 698px;">
<div class="list-group-item media border-bottom">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch0" v-model="hideAvatars">
<label class="custom-control-label" for="customSwitch0"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Hide Avatars
</div>
</div>
<div class="list-group-item media border-bottom">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch1" v-model="hideTimestamps">
<label class="custom-control-label" for="customSwitch1"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Hide Timestamps
</div>
</div>
<div class="list-group-item media border-bottom">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch2" v-model="largerText">
<label class="custom-control-label" for="customSwitch2"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Larger Text
</div>
</div>
<!-- <div class="list-group-item media border-bottom">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch3" v-model="autoRefresh">
<label class="custom-control-label" for="customSwitch3"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Auto Refresh
</div>
</div> -->
<div class="list-group-item media border-bottom d-flex align-items-center">
<div class="d-inline-block custom-control custom-switch ml-3">
<input type="checkbox" class="custom-control-input" id="customSwitch4" v-model="mutedNotifications">
<label class="custom-control-label" for="customSwitch4"></label>
</div>
<div class="d-inline-block ml-3 font-weight-bold">
Mute Notifications
<p class="small mb-0">You will not receive any direct message notifications from <strong>{{thread.username}}</strong>.</p>
</div>
</div>
</ul>
</div>
</div>
</div>
<b-modal ref="ctxModal"
id="ctx-modal"
hide-header
hide-footer
centered
rounded
size="sm"
body-class="list-group-flush p-0 rounded">
<div class="list-group text-center">
<div v-if="ctxContext && ctxContext.type == 'photo'" class="list-group-item rounded cursor-pointer font-weight-bold text-dark" @click="viewOriginal()">View Original</div>
<div v-if="ctxContext && ctxContext.type == 'video'" class="list-group-item rounded cursor-pointer font-weight-bold text-dark" @click="viewOriginal()">Play</div>
<div v-if="ctxContext && ctxContext.type == 'link'" class="list-group-item rounded cursor-pointer" @click="clickLink()">
<p class="mb-0" style="font-size:12px;">
Navigate to
</p>
<p class="mb-0 font-weight-bold text-dark">
{{this.ctxContext.meta.domain}}
</p>
</div>
<div v-if="ctxContext && (ctxContext.type == 'text' || ctxContext.type == 'emoji' || ctxContext.type == 'link')" class="list-group-item rounded cursor-pointer text-dark" @click="copyText()">Copy</div>
<div v-if="ctxContext && !ctxContext.isAuthor" class="list-group-item rounded cursor-pointer text-muted" @click="reportMessage()">Report</div>
<div v-if="ctxContext && ctxContext.isAuthor" class="list-group-item rounded cursor-pointer text-muted" @click="deleteMessage()">Delete</div>
<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxMenu()">Cancel</div>
</div>
</b-modal>
</div>
</template>
<style type="text/css" scoped>
.reply-btn {
position: absolute;
bottom: 54px;
right: 20px;
width: 90px;
text-align: center;
border-radius: 0 3px 3px 0;
}
.media-body .bg-primary {
background: linear-gradient(135deg, #2EA2F4 0%, #0B93F6 100%) !important;
}
.pill-to {
background:#EDF2F7;
font-weight: 500;
border-radius: 20px !important;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
margin-right: 3rem;
margin-bottom: 0.25rem;
}
.pill-from {
color: white !important;
text-align: right !important;
/*background: #53d769;*/
background: linear-gradient(135deg, #2EA2F4 0%, #0B93F6 100%) !important;
font-weight: 500;
border-radius: 20px !important;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
margin-left: 3rem;
margin-bottom: 0.25rem;
}
.chat-msg:hover {
background: #f7fbfd;
}
.no-focus:focus {
outline: none !important;
outline-width: 0 !important;
box-shadow: none;
-moz-box-shadow: none;
-webkit-box-shadow: none;
}
.emoji-msg {
font-size: 4rem !important;
line-height: 30px !important;
margin-top: 10px !important;
}
.larger-text {
font-size: 22px;
}
</style>
<script type="text/javascript">
export default {
props: ['accountId'],
data() {
return {
config: window.App.config,
hideAvatars: true,
hideTimestamps: false,
largerText: false,
autoRefresh: false,
mutedNotifications: false,
blocked: false,
loaded: false,
profile: {},
page: 'read',
pages: ['browse', 'add', 'read'],
threads: [],
thread: false,
threadIndex: false,
replyText: '',
composeUsername: '',
ctxContext: null,
ctxIndex: null,
uploading: false,
uploadProgress: null,
min_id: null,
max_id: null,
loadingMessages: false,
showLoadMore: true,
}
},
mounted() {
this.fetchProfile();
let self = this;
axios.get('/api/pixelfed/v1/direct/thread', {
params: {
pid: self.accountId
}
})
.then(res => {
self.loaded = true;
let d = res.data;
d.messages.reverse();
this.thread = d;
this.threads = [d];
this.threadIndex = 0;
let mids = d.messages.map(m => m.id);
this.max_id = Math.max(...mids);
this.min_id = Math.min(...mids);
this.mutedNotifications = d.muted;
this.markAsRead();
//this.messagePoll();
setTimeout(function() {
let objDiv = document.querySelector('.dm-wrapper');
objDiv.scrollTop = objDiv.scrollHeight;
}, 300);
});
},
watch: {
mutedNotifications: function(v) {
if(v) {
axios.post('/api/pixelfed/v1/direct/mute', {
id: this.accountId
}).then(res => {
});
} else {
axios.post('/api/pixelfed/v1/direct/unmute', {
id: this.accountId
}).then(res => {
});
}
this.mutedNotifications = v;
},
},
updated() {
$('[data-toggle="tooltip"]').tooltip();
},
methods: {
fetchProfile() {
axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
this.profile = res.data;
window._sharedData.curUser = res.data;
});
},
sendMessage() {
let self = this;
let rt = this.replyText;
axios.post('/api/pixelfed/v1/direct/create', {
'to_id': this.threads[this.threadIndex].id,
'message': rt,
'type': self.isEmoji(rt) && rt.length < 10 ? 'emoji' : 'text'
}).then(res => {
let msg = res.data;
self.threads[self.threadIndex].messages.push(msg);
let mids = self.threads[self.threadIndex].messages.map(m => m.id);
this.max_id = Math.max(...mids)
this.min_id = Math.min(...mids)
setTimeout(function() {
var objDiv = document.querySelector('.dm-wrapper');
objDiv.scrollTop = objDiv.scrollHeight;
}, 300);
}).catch(err => {
if(err.response.status == 403) {
self.blocked = true;
swal('Profile Unavailable', 'You cannot message this profile at this time.', 'error');
}
})
this.replyText = '';
},
openCtxMenu(r, i) {
this.ctxIndex = i;
this.ctxContext = r;
this.$refs.ctxModal.show();
},
closeCtxMenu() {
this.$refs.ctxModal.hide();
},
truncate(t) {
return _.truncate(t);
},
deleteMessage() {
let self = this;
let c = window.confirm('Are you sure you want to delete this message?');
if(c) {
axios.delete('/api/direct/message', {
params: {
id: self.ctxContext.id
}
}).then(res => {
self.threads[self.threadIndex].messages.splice(self.ctxIndex,1);
self.closeCtxMenu();
});
} else {
self.closeCtxMenu();
}
},
reportMessage() {
this.closeCtxMenu();
let url = '/i/report?type=post&id=' + this.ctxContext.id;
window.location.href = url;
return;
},
uploadMedia(event) {
let self = this;
$(document).on('change', '#uploadMedia', function(e) {
self.handleUpload();
});
let el = $(event.target);
el.attr('disabled', '');
$('#uploadMedia').click();
el.blur();
el.removeAttr('disabled');
},
handleUpload() {
let self = this;
self.uploading = true;
let io = document.querySelector('#uploadMedia');
if(!io.files.length) {
this.uploading = false;
}
Array.prototype.forEach.call(io.files, function(io, i) {
let type = io.type;
let acceptedMimes = self.config.uploader.media_types.split(',');
let validated = $.inArray(type, acceptedMimes);
if(validated == -1) {
swal('Invalid File Type', 'The file you are trying to add is not a valid mime type. Please upload a '+self.config.uploader.media_types+' only.', 'error');
self.uploading = false;
return;
}
let form = new FormData();
form.append('file', io);
form.append('to_id', self.threads[self.threadIndex].id);
let xhrConfig = {
onUploadProgress: function(e) {
let progress = Math.round( (e.loaded * 100) / e.total );
self.uploadProgress = progress;
}
};
axios.post('/api/direct/media', form, xhrConfig)
.then(function(e) {
self.uploadProgress = 100;
self.uploading = false;
let msg = {
id: Date.now(),
type: e.data.type,
isAuthor: true,
text: null,
media: e.data.url,
timeAgo: '1s',
seen: null
};
self.threads[self.threadIndex].messages.push(msg);
setTimeout(function() {
var objDiv = document.querySelector('.dm-wrapper');
objDiv.scrollTop = objDiv.scrollHeight;
}, 300);
}).catch(function(e) {
switch(e.response.status) {
case 451:
self.uploading = false;
io.value = null;
swal('Banned Content', 'This content has been banned and cannot be uploaded.', 'error');
break;
default:
self.uploading = false;
io.value = null;
swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error');
break;
}
});
io.value = null;
self.uploadProgress = 0;
});
},
viewOriginal() {
let url = this.ctxContext.media;
window.location.href = url;
return;
},
isEmoji(text) {
const onlyEmojis = text.replace(new RegExp('[\u0000-\u1eeff]', 'g'), '')
const visibleChars = text.replace(new RegExp('[\n\r\s]+|( )+', 'g'), '')
return onlyEmojis.length === visibleChars.length
},
copyText() {
window.App.util.clipboard(this.ctxContext.text);
this.closeCtxMenu();
return;
},
clickLink() {
let url = this.ctxContext.text;
if(this.ctxContext.meta.local != true) {
url = '/i/redirect?url=' + encodeURI(this.ctxContext.text);
}
window.location.href = url;
},
markAsRead() {
return;
axios.post('/api/direct/read', {
pid: this.accountId,
sid: this.max_id
}).then(res => {
}).catch(err => {
});
},
loadOlderMessages() {
let self = this;
this.loadingMessages = true;
axios.get('/api/pixelfed/v1/direct/thread', {
params: {
pid: this.accountId,
max_id: this.min_id,
}
}).then(res => {
let d = res.data;
if(!d.messages.length) {
this.showLoadMore = false;
this.loadingMessages = false;
return;
}
let cids = this.thread.messages.map(m => m.id);
let m = d.messages.filter(m => {
return cids.indexOf(m.id) == -1;
}).reverse();
let mids = m.map(m => m.id);
let min_id = Math.min(...mids);
if(min_id == this.min_id) {
this.showLoadMore = false;
this.loadingMessages = false;
return;
}
this.min_id = min_id;
this.thread.messages.unshift(...m);
setTimeout(function() {
self.loadingMessages = false;
}, 500);
}).catch(err => {
this.loadingMessages = false;
})
},
messagePoll() {
let self = this;
setInterval(function() {
axios.get('/api/pixelfed/v1/direct/thread', {
params: {
pid: self.accountId,
min_id: self.thread.messages[self.thread.messages.length - 1].id
}
}).then(res => {
});
}, 5000);
},
showOptions() {
this.page = 'options';
}
}
}
</script>

View File

@ -62,6 +62,11 @@
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> tagged you in a <a class="font-weight-bold" v-bind:href="n.tagged.post_url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'direct'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> sent a <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">dm</a>.
</p>
</div>
<div v-else>
<p class="my-0">
We cannot display this notification at this time.

View File

@ -102,17 +102,18 @@
</span>
<span v-if="profile.id != user.id && user.hasOwnProperty('id')">
<span class="pl-4" v-if="relationship.following == true">
<button type="button" class="btn btn-outline-secondary font-weight-bold btn-sm py-1" v-on:click="followProfile" data-toggle="tooltip" title="Unfollow">FOLLOWING</button>
<a :href="'/account/direct/t/'+profile.id" class="btn btn-outline-secondary font-weight-bold btn-sm py-1 text-dark mr-2 px-3 btn-sec-alt" style="border:1px solid #dbdbdb;" data-toggle="tooltip" title="Message">Message</a>
<button type="button" class="btn btn-outline-secondary font-weight-bold btn-sm py-1 text-dark btn-sec-alt" style="border:1px solid #dbdbdb;" v-on:click="followProfile" data-toggle="tooltip" title="Unfollow"><i class="fas fa-user-check mx-3"></i></button>
</span>
<span class="pl-4" v-if="!relationship.following">
<button type="button" class="btn btn-primary font-weight-bold btn-sm py-1" v-on:click="followProfile" data-toggle="tooltip" title="Follow">FOLLOW</button>
<button type="button" class="btn btn-primary font-weight-bold btn-sm py-1 px-3" v-on:click="followProfile" data-toggle="tooltip" title="Follow">Follow</button>
</span>
</span>
<span class="pl-4" v-if="owner && user.hasOwnProperty('id')">
<a class="btn btn-outline-secondary btn-sm" href="/settings/home" style="font-weight: 600;">Edit Profile</a>
</span>
<span class="pl-4">
<a class="fas fa-ellipsis-h fa-lg text-muted text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
<a class="fas fa-ellipsis-h fa-lg text-dark text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
</span>
</div>
<div class="font-size-16px">
@ -614,6 +615,12 @@
.modal-tab-active {
border-bottom: 1px solid #08d;
}
.btn-sec-alt:hover {
color: #ccc;
opacity: .7;
background-color: transparent;
border-color: #6c757d;
}
</style>
<script type="text/javascript">
import VueMasonry from 'vue-masonry-css'

View File

@ -24,7 +24,7 @@
<div class="col-12 mb-5">
<hr>
</div>
<div v-if="placesSearchEnabled && showPlaces" class="col-12">
<div v-if="placesSearchEnabled && showPlaces" class="col-12 mb-4">
<div class="mb-4">
<p class="text-secondary small font-weight-bold">PLACES <span class="pl-1 text-lighter">({{results.placesPagination.total}})</span></p>
</div>

View File

@ -1,4 +1,9 @@
Vue.component(
'direct-component',
require('./components/Direct.vue').default
);
Vue.component(
'direct-message',
require('./components/DirectMessage.vue').default
);

View File

@ -7,11 +7,6 @@
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/direct.js') }}"></script>
<script type="text/javascript">
new Vue({
el: '#content'
});
</script>
<script type="text/javascript">App.boot();</script>
@endpush

View File

@ -0,0 +1,12 @@
@extends('layouts.app')
@section('content')
<div>
<direct-message account-id="{{$id}}"></direct-message>
</div>
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/direct.js') }}"></script>
<script type="text/javascript">App.boot();</script>
@endpush

View File

@ -38,6 +38,12 @@
<span class="sr-only">Discover</span>
</a>
</li>
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold text-muted" href="/account/direct" title="Direct" data-toggle="tooltip" data-placement="bottom">
<i class="far fa-comment-dots fa-lg"></i>
<span class="sr-only">Direct</span>
</a>
</li>
<li class="nav-item px-md-2 d-none d-md-block">
<a class="nav-link font-weight-bold text-muted" href="/account/activity" title="Notifications" data-toggle="tooltip" data-placement="bottom">
<i class="far fa-bell fa-lg"></i>

View File

@ -40,13 +40,13 @@
</label>
<p class="text-muted small help-text">When this option is enabled, your profile and posts are used for discover recommendations. Only public profiles and posts are used.</p>
</div> --}}
{{--<div class="form-check pb-3">
<input class="form-check-input" type="checkbox" value="" id="dm">
<label class="form-check-label font-weight-bold" for="dm">
<div class="form-check pb-3">
<input class="form-check-input" type="checkbox" id="public_dm" {{$settings->public_dm ? 'checked=""':''}} name="public_dm">
<label class="form-check-label font-weight-bold" for="public_dm">
{{__('Receive Direct Messages from anyone')}}
</label>
<p class="text-muted small help-text">If selected, you will be able to receive messages from any user even if you do not follow them.</p>
</div>--}}
<p class="text-muted small help-text">If selected, you will be able to receive messages and notifications from any user even if you do not follow them.</p>
</div>
{{-- <div class="form-check pb-3">
<input class="form-check-input" type="checkbox" value="" id="srs" checked="">
<label class="form-check-label font-weight-bold" for="srs">

2
webpack.mix.js vendored
View File

@ -35,7 +35,7 @@ mix.js('resources/assets/js/app.js', 'public/js')
.js('resources/assets/js/profile-directory.js', 'public/js')
.js('resources/assets/js/story-compose.js', 'public/js')
// .js('resources/assets/js/embed.js', 'public')
// .js('resources/assets/js/direct.js', 'public/js')
.js('resources/assets/js/direct.js', 'public/js')
// .js('resources/assets/js/admin.js', 'public/js')
// .js('resources/assets/js/micro.js', 'public/js')
.js('resources/assets/js/rempro.js', 'public/js')